Compare commits

...

10 Commits

Author SHA1 Message Date
Rostislav Dugin
7a47be6ca6 Merge pull request #323 from databasus/develop
Develop
2026-02-01 18:42:30 +03:00
Rostislav Dugin
16be3db0c6 FIX (playground): Pre-select system storage if exists in playground 2026-02-01 18:30:50 +03:00
Rostislav Dugin
744e51d1e1 REFACTOR (email): Refactor commit adding date headers to emails 2026-02-01 16:43:53 +03:00
Rostislav Dugin
b3af75d430 Merge branch 'develop' of https://github.com/databasus/databasus into develop 2026-02-01 16:41:52 +03:00
mcarbs
6f7320abeb FIX (email): Add email date header 2026-02-01 16:41:17 +03:00
Rostislav Dugin
a1655d35a6 FIX (healthcheck): Add cache accessibility to healthcheck 2026-01-30 16:33:39 +03:00
Rostislav Dugin
9b6e801184 Merge pull request #316 from databasus/develop
FEATURE (email): Add sending email about members invitation and passw…
2026-01-28 17:29:58 +03:00
Rostislav Dugin
105777ab6f FEATURE (email): Add sending email about members invitation and password reset 2026-01-28 17:28:36 +03:00
Rostislav Dugin
3a1a88d5cf Merge pull request #315 from databasus/develop
FIX (env): Fix env detection over startup
2026-01-28 11:33:06 +03:00
Rostislav Dugin
699ca16814 FIX (env): Fix env detection over startup 2026-01-28 11:32:19 +03:00
39 changed files with 1907 additions and 59 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,4 @@
ansible/
postgresus_data/
postgresus-data/
databasus-data/
@@ -9,4 +10,5 @@ node_modules/
/articles
.DS_Store
/scripts
/scripts
.vscode/settings.json

View File

@@ -253,13 +253,22 @@ PG_BIN="/usr/lib/postgresql/17/bin"
# Generate runtime configuration for frontend
echo "Generating runtime configuration..."
cat > /app/ui/build/runtime-config.js << 'JSEOF'
# Detect if email is configured (both SMTP_HOST and DATABASUS_URL must be set)
if [ -n "\${SMTP_HOST:-}" ] && [ -n "\${DATABASUS_URL:-}" ]; then
IS_EMAIL_CONFIGURED="true"
else
IS_EMAIL_CONFIGURED="false"
fi
cat > /app/ui/build/runtime-config.js <<JSEOF
// Runtime configuration injected at container startup
// This file is generated dynamically and should not be edited manually
window.__RUNTIME_CONFIG__ = {
IS_CLOUD: '\${IS_CLOUD:-false}',
GITHUB_CLIENT_ID: '\${GITHUB_CLIENT_ID:-}',
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}'
GOOGLE_CLIENT_ID: '\${GOOGLE_CLIENT_ID:-}',
IS_EMAIL_CONFIGURED: '\$IS_EMAIL_CONFIGURED'
};
JSEOF

View File

@@ -114,6 +114,15 @@ type EnvVariables struct {
TestSupabaseUsername string `env:"TEST_SUPABASE_USERNAME"`
TestSupabasePassword string `env:"TEST_SUPABASE_PASSWORD"`
TestSupabaseDatabase string `env:"TEST_SUPABASE_DATABASE"`
// SMTP configuration (optional)
SMTPHost string `env:"SMTP_HOST"`
SMTPPort int `env:"SMTP_PORT"`
SMTPUser string `env:"SMTP_USER"`
SMTPPassword string `env:"SMTP_PASSWORD"`
// Application URL (optional) - used for email links
DatabasusURL string `env:"DATABASUS_URL"`
}
var (

View File

@@ -0,0 +1,22 @@
package email
import (
"databasus-backend/internal/config"
"databasus-backend/internal/util/logger"
)
var env = config.GetEnv()
var log = logger.GetLogger()
var emailSMTPSender = &EmailSMTPSender{
log,
env.SMTPHost,
env.SMTPPort,
env.SMTPUser,
env.SMTPPassword,
env.SMTPHost != "" && env.SMTPPort != 0,
}
func GetEmailSMTPSender() *EmailSMTPSender {
return emailSMTPSender
}

View File

@@ -0,0 +1,245 @@
package email
import (
"crypto/tls"
"fmt"
"log/slog"
"mime"
"net"
"net/smtp"
"time"
)
const (
ImplicitTLSPort = 465
DefaultTimeout = 5 * time.Second
DefaultHelloName = "localhost"
MIMETypeHTML = "text/html"
MIMECharsetUTF8 = "UTF-8"
)
type EmailSMTPSender struct {
logger *slog.Logger
smtpHost string
smtpPort int
smtpUser string
smtpPassword string
isConfigured bool
}
func (s *EmailSMTPSender) SendEmail(to, subject, body string) error {
if !s.isConfigured {
s.logger.Warn("Skipping email send, SMTP not initialized", "to", to, "subject", subject)
return nil
}
from := s.smtpUser
if from == "" {
from = "noreply@" + s.smtpHost
}
emailContent := s.buildEmailContent(to, subject, body, from)
isAuthRequired := s.smtpUser != "" && s.smtpPassword != ""
if s.smtpPort == ImplicitTLSPort {
return s.sendImplicitTLS(to, from, emailContent, isAuthRequired)
}
return s.sendStartTLS(to, from, emailContent, isAuthRequired)
}
func (s *EmailSMTPSender) buildEmailContent(to, subject, body, from string) []byte {
// Encode Subject header using RFC 2047 to avoid SMTPUTF8 requirement
encodedSubject := encodeRFC2047(subject)
subjectHeader := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
mimeHeaders := fmt.Sprintf(
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
MIMETypeHTML,
MIMECharsetUTF8,
)
// Encode From header display name if it contains non-ASCII
encodedFrom := encodeRFC2047(from)
fromHeader := fmt.Sprintf("From: %s\r\n", encodedFrom)
toHeader := fmt.Sprintf("To: %s\r\n", to)
return []byte(fromHeader + toHeader + subjectHeader + dateHeader + mimeHeaders + body)
}
func (s *EmailSMTPSender) sendImplicitTLS(
to, from string,
emailContent []byte,
isAuthRequired bool,
) error {
createClient := func() (*smtp.Client, func(), error) {
return s.createImplicitTLSClient()
}
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
if err != nil {
return err
}
defer cleanup()
return s.sendEmail(client, to, from, emailContent)
}
func (s *EmailSMTPSender) sendStartTLS(
to, from string,
emailContent []byte,
isAuthRequired bool,
) error {
createClient := func() (*smtp.Client, func(), error) {
return s.createStartTLSClient()
}
client, cleanup, err := s.authenticateWithRetry(createClient, isAuthRequired)
if err != nil {
return err
}
defer cleanup()
return s.sendEmail(client, to, from, emailContent)
}
func (s *EmailSMTPSender) createImplicitTLSClient() (*smtp.Client, func(), error) {
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
tlsConfig := &tls.Config{ServerName: s.smtpHost}
dialer := &net.Dialer{Timeout: DefaultTimeout}
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, s.smtpHost)
if err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
return client, func() { _ = client.Quit() }, nil
}
func (s *EmailSMTPSender) createStartTLSClient() (*smtp.Client, func(), error) {
addr := net.JoinHostPort(s.smtpHost, fmt.Sprintf("%d", s.smtpPort))
dialer := &net.Dialer{Timeout: DefaultTimeout}
conn, err := dialer.Dial("tcp", addr)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, s.smtpHost)
if err != nil {
_ = conn.Close()
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := client.Hello(DefaultHelloName); err != nil {
_ = client.Quit()
_ = conn.Close()
return nil, nil, fmt.Errorf("SMTP hello failed: %w", err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: s.smtpHost}); err != nil {
_ = client.Quit()
_ = conn.Close()
return nil, nil, fmt.Errorf("STARTTLS failed: %w", err)
}
}
return client, func() { _ = client.Quit() }, nil
}
func (s *EmailSMTPSender) authenticateWithRetry(
createClient func() (*smtp.Client, func(), error),
isAuthRequired bool,
) (*smtp.Client, func(), error) {
client, cleanup, err := createClient()
if err != nil {
return nil, nil, err
}
if !isAuthRequired {
return client, cleanup, nil
}
// Try PLAIN auth first
plainAuth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
if err := client.Auth(plainAuth); err == nil {
return client, cleanup, nil
}
// PLAIN auth failed, connection may be closed - recreate and try LOGIN auth
cleanup()
client, cleanup, err = createClient()
if err != nil {
return nil, nil, err
}
loginAuth := &loginAuth{username: s.smtpUser, password: s.smtpPassword}
if err := client.Auth(loginAuth); err != nil {
cleanup()
return nil, nil, fmt.Errorf("SMTP authentication failed: %w", err)
}
return client, cleanup, nil
}
func (s *EmailSMTPSender) sendEmail(client *smtp.Client, to, from string, content []byte) error {
if err := client.Mail(from); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
if _, err = writer.Write(content); err != nil {
return fmt.Errorf("failed to write email content: %w", err)
}
if err = writer.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil
}
func encodeRFC2047(s string) string {
return mime.QEncoding.Encode("UTF-8", s)
}
type loginAuth struct {
username string
password string
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte{}, nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:", "User Name\x00":
return []byte(a.username), nil
case "Password:", "Password\x00":
return []byte(a.password), nil
default:
return []byte(a.username), nil
}
}
return nil, nil
}

View File

@@ -130,6 +130,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
// This ensures compatibility with SMTP servers that don't support SMTPUTF8
encodedSubject := encodeRFC2047(heading)
subject := fmt.Sprintf("Subject: %s\r\n", encodedSubject)
dateHeader := fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
mimeHeaders := fmt.Sprintf(
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
@@ -143,7 +144,7 @@ func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
return []byte(fromHeader + toHeader + subject + mimeHeaders + message)
return []byte(fromHeader + toHeader + subject + dateHeader + mimeHeaders + message)
}
func (e *EmailNotifier) sendImplicitTLS(

View File

@@ -1,11 +1,14 @@
package system_healthcheck
import (
"context"
"databasus-backend/internal/config"
"databasus-backend/internal/features/backups/backups/backuping"
"databasus-backend/internal/features/disk"
"databasus-backend/internal/storage"
cache_utils "databasus-backend/internal/util/cache"
"errors"
"time"
)
type HealthcheckService struct {
@@ -15,6 +18,20 @@ type HealthcheckService struct {
}
func (s *HealthcheckService) IsHealthy() error {
return s.performHealthCheck()
}
func (s *HealthcheckService) performHealthCheck() error {
// Check if cache is available with PING
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
client := cache_utils.GetValkeyClient()
pingResult := client.Do(ctx, client.B().Ping().Build())
if pingResult.Error() != nil {
return errors.New("cannot connect to valkey")
}
diskUsage, err := s.diskService.GetDiskUsage()
if err != nil {
return errors.New("cannot get disk usage")
@@ -40,6 +57,7 @@ func (s *HealthcheckService) IsHealthy() error {
if config.GetEnv().IsProcessingNode {
if !s.backuperNode.IsBackuperRunning() {
return errors.New("backuper node is not running for more than 5 minutes")
}
}

View File

@@ -0,0 +1,593 @@
package users_controllers
import (
"net/http"
"testing"
"time"
users_dto "databasus-backend/internal/features/users/dto"
users_enums "databasus-backend/internal/features/users/enums"
users_models "databasus-backend/internal/features/users/models"
users_services "databasus-backend/internal/features/users/services"
users_testing "databasus-backend/internal/features/users/testing"
"databasus-backend/internal/storage"
test_utils "databasus-backend/internal/util/testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"golang.org/x/crypto/bcrypt"
)
func Test_SendResetPasswordCode_WithValidEmail_CodeSent(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: user.Email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusOK,
)
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
assert.Equal(t, user.Email, mockEmailSender.SentEmails[0].To)
assert.Contains(t, mockEmailSender.SentEmails[0].Subject, "Password Reset")
}
func Test_SendResetPasswordCode_WithNonExistentUser_ReturnsSuccess(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: "nonexistent" + uuid.New().String() + "@example.com",
}
// Should return success to prevent enumeration attacks
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusOK,
)
// But no email should be sent
assert.Equal(t, 0, len(mockEmailSender.SentEmails))
}
func Test_SendResetPasswordCode_WithInvitedUser_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
adminUser := users_testing.CreateTestUser(users_enums.UserRoleAdmin)
email := "invited" + uuid.New().String() + "@example.com"
inviteRequest := users_dto.InviteUserRequestDTO{
Email: email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/invite",
"Bearer "+adminUser.Token,
inviteRequest,
http.StatusOK,
)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: email,
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "only active users")
}
func Test_SendResetPasswordCode_WithRateLimitExceeded_ReturnsTooManyRequests(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: user.Email,
}
// Make 3 requests (should succeed)
for range 3 {
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusOK,
)
}
// 4th request should be rate limited
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusTooManyRequests,
)
assert.Contains(t, string(resp.Body), "Rate limit exceeded")
}
func Test_SendResetPasswordCode_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
Method: "POST",
URL: "/api/v1/users/send-reset-password-code",
Body: "invalid json",
ExpectedStatus: http.StatusBadRequest,
})
assert.Contains(t, string(resp.Body), "Invalid request format")
}
func Test_ResetPassword_WithValidCode_PasswordReset(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
email := "resettest" + uuid.New().String() + "@example.com"
oldPassword := "oldpassword123"
newPassword := "newpassword456"
// Create user
signupRequest := users_dto.SignUpRequestDTO{
Email: email,
Password: oldPassword,
Name: "Test User",
}
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
// Request reset code
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
Email: email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
sendCodeRequest,
http.StatusOK,
)
// Extract code from email
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
emailBody := mockEmailSender.SentEmails[0].Body
code := extractCodeFromEmail(emailBody)
t.Logf("Extracted code: %s from email body (length: %d)", code, len(code))
assert.NotEmpty(t, code, "Code should be extracted from email")
assert.Len(t, code, 6, "Code should be 6 digits")
// Reset password
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: email,
Code: code,
NewPassword: newPassword,
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusOK,
)
if resp.StatusCode != http.StatusOK {
t.Logf("Reset password failed with body: %s", string(resp.Body))
}
// Verify old password doesn't work
oldSigninRequest := users_dto.SignInRequestDTO{
Email: email,
Password: oldPassword,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/signin",
"",
oldSigninRequest,
http.StatusBadRequest,
)
// Verify new password works
newSigninRequest := users_dto.SignInRequestDTO{
Email: email,
Password: newPassword,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/signin",
"",
newSigninRequest,
http.StatusOK,
)
}
func Test_ResetPassword_WithExpiredCode_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
// Create expired reset code directly in database
code := "123456"
hashedCode, _ := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
expiredCode := &users_models.PasswordResetCode{
ID: uuid.New(),
UserID: user.UserID,
HashedCode: string(hashedCode),
ExpiresAt: time.Now().UTC().Add(-1 * time.Hour), // Expired 1 hour ago
IsUsed: false,
CreatedAt: time.Now().UTC().Add(-2 * time.Hour),
}
storage.GetDb().Create(expiredCode)
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: user.Email,
Code: code,
NewPassword: "newpassword123",
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "invalid or expired")
}
func Test_ResetPassword_WithUsedCode_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
email := "usedcode" + uuid.New().String() + "@example.com"
// Create user
signupRequest := users_dto.SignUpRequestDTO{
Email: email,
Password: "password123",
Name: "Test User",
}
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
// Request reset code
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
Email: email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
sendCodeRequest,
http.StatusOK,
)
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
// Use code first time
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: email,
Code: code,
NewPassword: "newpassword123",
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusOK,
)
// Try to use same code again
resetRequest2 := users_dto.ResetPasswordRequestDTO{
Email: email,
Code: code,
NewPassword: "anotherpassword456",
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest2,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "invalid or expired")
}
func Test_ResetPassword_WithWrongCode_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
// Request reset code
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
Email: user.Email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
sendCodeRequest,
http.StatusOK,
)
// Try to reset with wrong code
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: user.Email,
Code: "999999", // Wrong code
NewPassword: "newpassword123",
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "invalid")
}
func Test_ResetPassword_WithInvalidNewPassword_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: user.Email,
Code: "123456",
NewPassword: "short", // Too short
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusBadRequest,
)
}
func Test_ResetPassword_EmailSendFailure_ReturnsError(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
mockEmailSender.ShouldFail = true
users_services.GetUserService().SetEmailSender(mockEmailSender)
user := users_testing.CreateTestUser(users_enums.UserRoleMember)
request := users_dto.SendResetPasswordCodeRequestDTO{
Email: user.Email,
}
resp := test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
request,
http.StatusBadRequest,
)
assert.Contains(t, string(resp.Body), "failed to send email")
}
func Test_ResetPasswordFlow_E2E_CompletesSuccessfully(t *testing.T) {
router := createUserTestRouter()
mockEmailSender := users_testing.NewMockEmailSender()
users_services.GetUserService().SetEmailSender(mockEmailSender)
email := "e2e" + uuid.New().String() + "@example.com"
initialPassword := "initialpass123"
newPassword := "brandnewpass456"
// 1. Create user via signup
signupRequest := users_dto.SignUpRequestDTO{
Email: email,
Password: initialPassword,
Name: "E2E Test User",
}
test_utils.MakePostRequest(t, router, "/api/v1/users/signup", "", signupRequest, http.StatusOK)
// 2. Verify can sign in with initial password
signinRequest := users_dto.SignInRequestDTO{
Email: email,
Password: initialPassword,
}
var signinResponse users_dto.SignInResponseDTO
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/users/signin",
"",
signinRequest,
http.StatusOK,
&signinResponse,
)
assert.NotEmpty(t, signinResponse.Token)
// 3. Request password reset code
sendCodeRequest := users_dto.SendResetPasswordCodeRequestDTO{
Email: email,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/send-reset-password-code",
"",
sendCodeRequest,
http.StatusOK,
)
// 4. Verify email was sent
assert.Equal(t, 1, len(mockEmailSender.SentEmails))
code := extractCodeFromEmail(mockEmailSender.SentEmails[0].Body)
assert.NotEmpty(t, code)
// 5. Reset password using code
resetRequest := users_dto.ResetPasswordRequestDTO{
Email: email,
Code: code,
NewPassword: newPassword,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/reset-password",
"",
resetRequest,
http.StatusOK,
)
// 6. Verify old password no longer works
oldSignin := users_dto.SignInRequestDTO{
Email: email,
Password: initialPassword,
}
test_utils.MakePostRequest(
t,
router,
"/api/v1/users/signin",
"",
oldSignin,
http.StatusBadRequest,
)
// 7. Verify new password works
newSignin := users_dto.SignInRequestDTO{
Email: email,
Password: newPassword,
}
var finalResponse users_dto.SignInResponseDTO
test_utils.MakePostRequestAndUnmarshal(
t,
router,
"/api/v1/users/signin",
"",
newSignin,
http.StatusOK,
&finalResponse,
)
assert.NotEmpty(t, finalResponse.Token)
}
func Test_ResetPassword_WithInvalidJSON_ReturnsBadRequest(t *testing.T) {
router := createUserTestRouter()
resp := test_utils.MakeRequest(t, router, test_utils.RequestOptions{
Method: "POST",
URL: "/api/v1/users/reset-password",
Body: "invalid json",
ExpectedStatus: http.StatusBadRequest,
})
assert.Contains(t, string(resp.Body), "Invalid request format")
}
// Helper function to extract 6-digit code from email HTML body
func extractCodeFromEmail(emailBody string) string {
// Look for pattern: <h1 ... >CODE</h1>
// First find <h1
h1Start := 0
for i := 0; i < len(emailBody)-3; i++ {
if emailBody[i:i+3] == "<h1" {
h1Start = i
break
}
}
if h1Start == 0 {
return ""
}
// Find the > after <h1
contentStart := h1Start
for i := h1Start; i < len(emailBody); i++ {
if emailBody[i] == '>' {
contentStart = i + 1
break
}
}
// Find </h1>
contentEnd := contentStart
for i := contentStart; i < len(emailBody)-5; i++ {
if emailBody[i:i+5] == "</h1>" {
contentEnd = i
break
}
}
if contentEnd <= contentStart {
return ""
}
// Extract content and remove whitespace
content := emailBody[contentStart:contentEnd]
code := ""
for i := 0; i < len(content); i++ {
if isDigit(content[i]) {
code += string(content[i])
}
}
if len(code) == 6 {
return code
}
return ""
}
func isDigit(b byte) bool {
return b >= '0' && b <= '9'
}

View File

@@ -28,6 +28,10 @@ func (c *UserController) RegisterRoutes(router *gin.RouterGroup) {
router.GET("/users/admin/has-password", c.IsAdminHasPassword)
router.POST("/users/admin/set-password", c.SetAdminPassword)
// Password reset (no auth required)
router.POST("/users/send-reset-password-code", c.SendResetPasswordCode)
router.POST("/users/reset-password", c.ResetPassword)
// OAuth callbacks
router.POST("/auth/github/callback", c.HandleGitHubOAuth)
router.POST("/auth/google/callback", c.HandleGoogleOAuth)
@@ -340,3 +344,70 @@ func (c *UserController) HandleGoogleOAuth(ctx *gin.Context) {
ctx.JSON(http.StatusOK, response)
}
// SendResetPasswordCode
// @Summary Send password reset code
// @Description Send a password reset code to the user's email
// @Tags users
// @Accept json
// @Produce json
// @Param request body users_dto.SendResetPasswordCodeRequestDTO true "Email address"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 429 {object} map[string]string
// @Router /users/send-reset-password-code [post]
func (c *UserController) SendResetPasswordCode(ctx *gin.Context) {
var request user_dto.SendResetPasswordCodeRequestDTO
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
allowed, _ := c.rateLimiter.CheckLimit(
request.Email,
"reset-password",
3,
1*time.Hour,
)
if !allowed {
ctx.JSON(
http.StatusTooManyRequests,
gin.H{"error": "Rate limit exceeded. Please try again later."},
)
return
}
err := c.userService.SendResetPasswordCode(request.Email)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "If the email exists, a reset code has been sent"})
}
// ResetPassword
// @Summary Reset password with code
// @Description Reset user password using the code sent via email
// @Tags users
// @Accept json
// @Produce json
// @Param request body users_dto.ResetPasswordRequestDTO true "Reset password data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /users/reset-password [post]
func (c *UserController) ResetPassword(ctx *gin.Context) {
var request user_dto.ResetPasswordRequestDTO
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}
err := c.userService.ResetPassword(request.Email, request.Code, request.NewPassword)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "Password reset successfully"})
}

View File

@@ -92,3 +92,13 @@ type OAuthCallbackResponseDTO struct {
Token string `json:"token"`
IsNewUser bool `json:"isNewUser"`
}
type SendResetPasswordCodeRequestDTO struct {
Email string `json:"email" binding:"required,email"`
}
type ResetPasswordRequestDTO struct {
Email string `json:"email" binding:"required,email"`
Code string `json:"code" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=8"`
}

View File

@@ -7,3 +7,7 @@ import (
type AuditLogWriter interface {
WriteAuditLog(message string, userID *uuid.UUID, workspaceID *uuid.UUID)
}
type EmailSender interface {
SendEmail(to, subject, body string) error
}

View File

@@ -0,0 +1,24 @@
package users_models
import (
"time"
"github.com/google/uuid"
)
type PasswordResetCode struct {
ID uuid.UUID `json:"id" gorm:"column:id"`
UserID uuid.UUID `json:"userId" gorm:"column:user_id"`
HashedCode string `json:"-" gorm:"column:hashed_code"`
ExpiresAt time.Time `json:"expiresAt" gorm:"column:expires_at"`
IsUsed bool `json:"isUsed" gorm:"column:is_used"`
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
}
func (PasswordResetCode) TableName() string {
return "password_reset_codes"
}
func (p *PasswordResetCode) IsValid() bool {
return !p.IsUsed && time.Now().UTC().Before(p.ExpiresAt)
}

View File

@@ -2,6 +2,7 @@ package users_repositories
var userRepository = &UserRepository{}
var usersSettingsRepository = &UsersSettingsRepository{}
var passwordResetRepository = &PasswordResetRepository{}
func GetUserRepository() *UserRepository {
return userRepository
@@ -10,3 +11,7 @@ func GetUserRepository() *UserRepository {
func GetUsersSettingsRepository() *UsersSettingsRepository {
return usersSettingsRepository
}
func GetPasswordResetRepository() *PasswordResetRepository {
return passwordResetRepository
}

View File

@@ -0,0 +1,61 @@
package users_repositories
import (
"time"
users_models "databasus-backend/internal/features/users/models"
"databasus-backend/internal/storage"
"github.com/google/uuid"
)
type PasswordResetRepository struct{}
func (r *PasswordResetRepository) CreateResetCode(code *users_models.PasswordResetCode) error {
if code.ID == uuid.Nil {
code.ID = uuid.New()
}
return storage.GetDb().Create(code).Error
}
func (r *PasswordResetRepository) GetValidCodeByUserID(
userID uuid.UUID,
) (*users_models.PasswordResetCode, error) {
var code users_models.PasswordResetCode
err := storage.GetDb().
Where("user_id = ? AND is_used = ? AND expires_at > ?", userID, false, time.Now().UTC()).
Order("created_at DESC").
First(&code).Error
if err != nil {
return nil, err
}
return &code, nil
}
func (r *PasswordResetRepository) MarkCodeAsUsed(codeID uuid.UUID) error {
return storage.GetDb().Model(&users_models.PasswordResetCode{}).
Where("id = ?", codeID).
Update("is_used", true).Error
}
func (r *PasswordResetRepository) DeleteExpiredCodes() error {
return storage.GetDb().
Where("expires_at < ?", time.Now().UTC()).
Delete(&users_models.PasswordResetCode{}).Error
}
func (r *PasswordResetRepository) CountRecentCodesByUserID(
userID uuid.UUID,
since time.Time,
) (int64, error) {
var count int64
err := storage.GetDb().Model(&users_models.PasswordResetCode{}).
Where("user_id = ? AND created_at > ?", userID, since).
Count(&count).Error
return count, err
}

View File

@@ -1,6 +1,7 @@
package users_services
import (
"databasus-backend/internal/features/email"
"databasus-backend/internal/features/encryption/secrets"
users_repositories "databasus-backend/internal/features/users/repositories"
)
@@ -10,6 +11,8 @@ var userService = &UserService{
secrets.GetSecretKeyService(),
settingsService,
nil,
email.GetEmailSMTPSender(),
users_repositories.GetPasswordResetRepository(),
}
var settingsService = &SettingsService{
users_repositories.GetUsersSettingsRepository(),

View File

@@ -2,6 +2,7 @@ package users_services
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
@@ -27,16 +28,22 @@ import (
)
type UserService struct {
userRepository *users_repositories.UserRepository
secretKeyService *secrets.SecretKeyService
settingsService *SettingsService
auditLogWriter users_interfaces.AuditLogWriter
userRepository *users_repositories.UserRepository
secretKeyService *secrets.SecretKeyService
settingsService *SettingsService
auditLogWriter users_interfaces.AuditLogWriter
emailSender users_interfaces.EmailSender
passwordResetRepository *users_repositories.PasswordResetRepository
}
func (s *UserService) SetAuditLogWriter(writer users_interfaces.AuditLogWriter) {
s.auditLogWriter = writer
}
func (s *UserService) SetEmailSender(sender users_interfaces.EmailSender) {
s.emailSender = sender
}
func (s *UserService) SignUp(request *users_dto.SignUpRequestDTO) error {
existingUser, err := s.userRepository.GetUserByEmail(request.Email)
if err != nil {
@@ -798,3 +805,164 @@ func (s *UserService) fetchGitHubPrimaryEmail(
return "", errors.New("github account has no accessible email")
}
func (s *UserService) SendResetPasswordCode(email string) error {
user, err := s.userRepository.GetUserByEmail(email)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
// Silently succeed for non-existent users to prevent enumeration attacks
if user == nil {
return nil
}
// Only active users can reset passwords
if user.Status != users_enums.UserStatusActive {
return errors.New("only active users can reset their password")
}
// Check rate limiting - max 3 codes per hour
oneHourAgo := time.Now().UTC().Add(-1 * time.Hour)
recentCount, err := s.passwordResetRepository.CountRecentCodesByUserID(user.ID, oneHourAgo)
if err != nil {
return fmt.Errorf("failed to check rate limit: %w", err)
}
if recentCount >= 3 {
return errors.New("too many password reset attempts, please try again later")
}
// Generate 6-digit random code using crypto/rand for better randomness
codeNum := make([]byte, 4)
_, err = io.ReadFull(rand.Reader, codeNum)
if err != nil {
return fmt.Errorf("failed to generate random code: %w", err)
}
// Convert bytes to uint32 and modulo to get 6 digits
randomInt := uint32(
codeNum[0],
)<<24 | uint32(
codeNum[1],
)<<16 | uint32(
codeNum[2],
)<<8 | uint32(
codeNum[3],
)
code := fmt.Sprintf("%06d", randomInt%1000000)
// Hash the code
hashedCode, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash code: %w", err)
}
// Store in database with 1 hour expiration
resetCode := &users_models.PasswordResetCode{
ID: uuid.New(),
UserID: user.ID,
HashedCode: string(hashedCode),
ExpiresAt: time.Now().UTC().Add(1 * time.Hour),
IsUsed: false,
CreatedAt: time.Now().UTC(),
}
if err := s.passwordResetRepository.CreateResetCode(resetCode); err != nil {
return fmt.Errorf("failed to create reset code: %w", err)
}
// Send email with code
if s.emailSender != nil {
subject := "Password Reset Code"
body := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; padding: 20px;">
<h2 style="color: #333333; margin-bottom: 20px;">Password Reset Request</h2>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
You have requested to reset your password. Please use the following code to complete the password reset process:
</p>
<div style="background-color: #f8f9fa; border: 2px solid #e9ecef; border-radius: 8px; padding: 20px; text-align: center; margin: 30px 0;">
<h1 style="color: #2c3e50; font-size: 36px; margin: 0; letter-spacing: 8px; font-family: monospace;">%s</h1>
</div>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
This code will expire in <strong>1 hour</strong>.
</p>
<p style="color: #666666; line-height: 1.6; margin-bottom: 20px;">
If you did not request a password reset, please ignore this email. Your password will remain unchanged.
</p>
<hr style="border: none; border-top: 1px solid #e9ecef; margin: 30px 0;">
<p style="color: #999999; font-size: 12px; line-height: 1.6;">
This is an automated message. Please do not reply to this email.
</p>
</div>
</body>
</html>
`, code)
if err := s.emailSender.SendEmail(user.Email, subject, body); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
}
// Audit log
if s.auditLogWriter != nil {
s.auditLogWriter.WriteAuditLog(
fmt.Sprintf("Password reset code sent to: %s", user.Email),
&user.ID,
nil,
)
}
return nil
}
func (s *UserService) ResetPassword(email, code, newPassword string) error {
user, err := s.userRepository.GetUserByEmail(email)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
if user == nil {
return errors.New("user with this email does not exist")
}
// Get valid reset code for user
resetCode, err := s.passwordResetRepository.GetValidCodeByUserID(user.ID)
if err != nil {
return errors.New("invalid or expired reset code")
}
// Verify code matches
err = bcrypt.CompareHashAndPassword([]byte(resetCode.HashedCode), []byte(code))
if err != nil {
return errors.New("invalid reset code")
}
// Mark code as used
if err := s.passwordResetRepository.MarkCodeAsUsed(resetCode.ID); err != nil {
return fmt.Errorf("failed to mark code as used: %w", err)
}
// Update user password
if err := s.ChangeUserPassword(user.ID, newPassword); err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
// Audit log
if s.auditLogWriter != nil {
s.auditLogWriter.WriteAuditLog(
"Password reset via email code",
&user.ID,
nil,
)
}
return nil
}

View File

@@ -0,0 +1,33 @@
package users_testing
import "errors"
type MockEmailSender struct {
SentEmails []EmailCall
ShouldFail bool
}
type EmailCall struct {
To string
Subject string
Body string
}
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
m.SentEmails = append(m.SentEmails, EmailCall{
To: to,
Subject: subject,
Body: body,
})
if m.ShouldFail {
return errors.New("mock email send failure")
}
return nil
}
func NewMockEmailSender() *MockEmailSender {
return &MockEmailSender{
SentEmails: []EmailCall{},
ShouldFail: false,
}
}

View File

@@ -5,3 +5,7 @@ import "github.com/google/uuid"
type WorkspaceDeletionListener interface {
OnBeforeWorkspaceDeletion(workspaceID uuid.UUID) error
}
type EmailSender interface {
SendEmail(to, subject, body string) error
}

View File

@@ -2,9 +2,11 @@ package workspaces_services
import (
"databasus-backend/internal/features/audit_logs"
"databasus-backend/internal/features/email"
users_services "databasus-backend/internal/features/users/services"
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
"databasus-backend/internal/util/logger"
)
var workspaceRepository = &workspaces_repositories.WorkspaceRepository{}
@@ -26,6 +28,8 @@ var membershipService = &MembershipService{
audit_logs.GetAuditLogService(),
workspaceService,
users_services.GetSettingsService(),
email.GetEmailSMTPSender(),
logger.GetLogger(),
}
func GetWorkspaceService() *WorkspaceService {

View File

@@ -2,7 +2,9 @@ package workspaces_services
import (
"fmt"
"log/slog"
"databasus-backend/internal/config"
audit_logs "databasus-backend/internal/features/audit_logs"
users_dto "databasus-backend/internal/features/users/dto"
users_enums "databasus-backend/internal/features/users/enums"
@@ -10,6 +12,7 @@ import (
users_services "databasus-backend/internal/features/users/services"
workspaces_dto "databasus-backend/internal/features/workspaces/dto"
workspaces_errors "databasus-backend/internal/features/workspaces/errors"
workspaces_interfaces "databasus-backend/internal/features/workspaces/interfaces"
workspaces_models "databasus-backend/internal/features/workspaces/models"
workspaces_repositories "databasus-backend/internal/features/workspaces/repositories"
@@ -23,6 +26,8 @@ type MembershipService struct {
auditLogService *audit_logs.AuditLogService
workspaceService *WorkspaceService
settingsService *users_services.SettingsService
emailSender workspaces_interfaces.EmailSender
logger *slog.Logger
}
func (s *MembershipService) GetMembers(
@@ -77,6 +82,12 @@ func (s *MembershipService) AddMember(
return nil, workspaces_errors.ErrInsufficientPermissionsToInviteUsers
}
// Get workspace details for email
workspace, err := s.workspaceRepository.GetWorkspaceByID(workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to get workspace: %w", err)
}
inviteRequest := &users_dto.InviteUserRequestDTO{
Email: request.Email,
IntendedWorkspaceID: &workspaceID,
@@ -88,6 +99,14 @@ func (s *MembershipService) AddMember(
return nil, err
}
// Send invitation email
subject := fmt.Sprintf("You've been invited to %s workspace", workspace.Name)
body := s.buildInvitationEmailHTML(workspace.Name, addedBy.Name, string(request.Role))
if err := s.emailSender.SendEmail(request.Email, subject, body); err != nil {
s.logger.Error("Failed to send invitation email", "email", request.Email, "error", err)
}
membership := &workspaces_models.WorkspaceMembership{
UserID: inviteResponse.ID,
WorkspaceID: workspaceID,
@@ -339,3 +358,48 @@ func (s *MembershipService) validateCanManageMembership(
return nil
}
func (s *MembershipService) buildInvitationEmailHTML(
workspaceName, inviterName, role string,
) string {
env := config.GetEnv()
signUpLink := ""
if env.DatabasusURL != "" {
signUpLink = fmt.Sprintf(`<p style="margin: 20px 0;">
<a href="%s/sign-up" style="display: inline-block; padding: 12px 24px; background-color: #0d6efd; color: white; text-decoration: none; border-radius: 4px;">
Sign up
</a>
</p>`, env.DatabasusURL)
} else {
signUpLink = `<p style="margin: 20px 0; color: #666;">
Please visit your Databasus instance to sign up and access the workspace.
</p>`
}
return fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background-color: #f8f9fa; border-radius: 8px; padding: 30px; margin: 20px 0;">
<h1 style="color: #0d6efd; margin-top: 0;">Workspace Invitation</h1>
<p style="font-size: 16px; margin: 20px 0;">
<strong>%s</strong> has invited you to join the <strong>%s</strong> workspace as a <strong>%s</strong>.
</p>
%s
<hr style="border: none; border-top: 1px solid #dee2e6; margin: 30px 0;">
<p style="font-size: 14px; color: #6c757d; margin: 0;">
This is an automated message from Databasus. If you didn't expect this invitation, you can safely ignore this email.
</p>
</div>
</body>
</html>
`, inviterName, workspaceName, role, signUpLink)
}

View File

@@ -0,0 +1,33 @@
package workspaces_testing
import "errors"
type MockEmailSender struct {
SendEmailCalls []EmailCall
ShouldFail bool
}
type EmailCall struct {
To string
Subject string
Body string
}
func (m *MockEmailSender) SendEmail(to, subject, body string) error {
m.SendEmailCalls = append(m.SendEmailCalls, EmailCall{
To: to,
Subject: subject,
Body: body,
})
if m.ShouldFail {
return errors.New("mock email send failure")
}
return nil
}
func NewMockEmailSender() *MockEmailSender {
return &MockEmailSender{
SendEmailCalls: []EmailCall{},
ShouldFail: false,
}
}

View File

@@ -0,0 +1,31 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE password_reset_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
hashed_code TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE password_reset_codes
ADD CONSTRAINT fk_password_reset_codes_user_id
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE;
CREATE INDEX idx_password_reset_codes_user_id ON password_reset_codes (user_id);
CREATE INDEX idx_password_reset_codes_expires_at ON password_reset_codes (expires_at);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_password_reset_codes_expires_at;
DROP INDEX IF EXISTS idx_password_reset_codes_user_id;
DROP TABLE IF EXISTS password_reset_codes;
-- +goose StatementEnd

View File

@@ -1 +1,5 @@
MODE=development
MODE=development
VITE_GITHUB_CLIENT_ID=
VITE_GOOGLE_CLIENT_ID=
VITE_IS_EMAIL_CONFIGURED=false
VITE_IS_CLOUD=false

View File

@@ -2,6 +2,7 @@ interface RuntimeConfig {
IS_CLOUD?: string;
GITHUB_CLIENT_ID?: string;
GOOGLE_CLIENT_ID?: string;
IS_EMAIL_CONFIGURED?: string;
}
declare global {
@@ -27,7 +28,6 @@ export const GOOGLE_DRIVE_OAUTH_REDIRECT_URL = 'https://databasus.com/storages/g
export const APP_VERSION = (import.meta.env.VITE_APP_VERSION as string) || 'dev';
// First try runtime config, then build-time env var, then default to false
export const IS_CLOUD =
window.__RUNTIME_CONFIG__?.IS_CLOUD === 'true' || import.meta.env.VITE_IS_CLOUD === 'true';
@@ -37,6 +37,10 @@ export const GITHUB_CLIENT_ID =
export const GOOGLE_CLIENT_ID =
window.__RUNTIME_CONFIG__?.GOOGLE_CLIENT_ID || import.meta.env.VITE_GOOGLE_CLIENT_ID || '';
export const IS_EMAIL_CONFIGURED =
window.__RUNTIME_CONFIG__?.IS_EMAIL_CONFIGURED === 'true' ||
import.meta.env.VITE_IS_EMAIL_CONFIGURED === 'true';
export function getOAuthRedirectUri(): string {
return `${window.location.origin}/auth/callback`;
}

View File

@@ -8,6 +8,8 @@ import type { InviteUserResponse } from '../model/InviteUserResponse';
import type { IsAdminHasPasswordResponse } from '../model/IsAdminHasPasswordResponse';
import type { OAuthCallbackRequest } from '../model/OAuthCallbackRequest';
import type { OAuthCallbackResponse } from '../model/OAuthCallbackResponse';
import type { ResetPasswordRequest } from '../model/ResetPasswordRequest';
import type { SendResetPasswordCodeRequest } from '../model/SendResetPasswordCodeRequest';
import type { SetAdminPasswordRequest } from '../model/SetAdminPasswordRequest';
import type { SignInRequest } from '../model/SignInRequest';
import type { SignInResponse } from '../model/SignInResponse';
@@ -134,6 +136,24 @@ export const userApi = {
});
},
async sendResetPasswordCode(request: SendResetPasswordCodeRequest): Promise<{ message: string }> {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(request));
return apiHelper.fetchPostJson(
`${getApplicationServer()}/api/v1/users/send-reset-password-code`,
requestOptions,
);
},
async resetPassword(request: ResetPasswordRequest): Promise<{ message: string }> {
const requestOptions: RequestOptions = new RequestOptions();
requestOptions.setBody(JSON.stringify(request));
return apiHelper.fetchPostJson(
`${getApplicationServer()}/api/v1/users/reset-password`,
requestOptions,
);
},
isAuthorized: (): boolean => !!accessTokenHelper.getAccessToken(),
logout: () => {

View File

@@ -18,5 +18,7 @@ export type { ListUsersRequest } from './model/ListUsersRequest';
export type { ListUsersResponse } from './model/ListUsersResponse';
export type { ChangeUserRoleRequest } from './model/ChangeUserRoleRequest';
export type { UsersSettings } from './model/UsersSettings';
export type { SendResetPasswordCodeRequest } from './model/SendResetPasswordCodeRequest';
export type { ResetPasswordRequest } from './model/ResetPasswordRequest';
export { UserRole } from './model/UserRole';
export { WorkspaceRole } from './model/WorkspaceRole';

View File

@@ -0,0 +1,5 @@
export interface ResetPasswordRequest {
email: string;
code: string;
newPassword: string;
}

View File

@@ -0,0 +1,3 @@
export interface SendResetPasswordCodeRequest {
email: string;
}

View File

@@ -204,6 +204,13 @@ export const EditBackupConfigComponent = ({
try {
const storages = await storageApi.getStorages(database.workspaceId);
setStorages(storages);
if (IS_CLOUD) {
const systemStorages = storages.filter((s) => s.isSystem);
if (systemStorages.length > 0) {
updateBackupConfig({ storage: systemStorages[0] });
}
}
} catch (e) {
alert((e as Error).message);
}

View File

@@ -59,7 +59,7 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
return (
<Modal
title="Welcome to Databasus Playground"
title="Welcome to Databasus playground"
open={isVisible}
onOk={handleClose}
okText={
@@ -78,9 +78,10 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
<div>
<h3 className="mb-2 text-lg font-semibold">What is Playground?</h3>
<p className="text-gray-700 dark:text-gray-300">
Playground is a dev environment where you can test small databases backup and see
Databasus in action. Databasus dev team can test new features and see issues which hard
to detect when using self hosted (without logs or reports)
Playground is a dev environment of Databasus development team. It is used by Databasus
dev team to test new features and see issues which hard to detect when using self hosted
(without logs or reports).{' '}
<b>Here you can make backups for small and not critical databases for free</b>
</p>
</div>
@@ -114,7 +115,8 @@ export const PlaygroundWarningComponent = (): JSX.Element => {
No, because playground use only read-only users and cannot affect your DB. Only issue
you can face is instability: playground background workers frequently reloaded so backup
can be slower or be restarted due to app restart. Do not rely production DBs on
playground, please
playground, please. At once we may clean backups or something like this. At least, check
your backups here once a week
</p>
</div>

View File

@@ -42,7 +42,7 @@ export const StorageCardComponent = ({
)}
{storage.isSystem && (
<div className="mt-2 inline-block rounded-lg bg-[#ffffff10] px-2 py-1 text-xs text-gray-700 dark:text-gray-300">
<div className="mt-2 inline-block rounded-xl bg-[#00000010] px-2 py-1 text-xs text-gray-700 dark:bg-[#ffffff10] dark:text-gray-300">
System storage
</div>
)}

View File

@@ -143,15 +143,22 @@ export const StorageComponent = ({
) : (
<div>
{!isEditName ? (
<div className="mb-5 flex items-center text-2xl font-bold">
{storage.name}
{(storage.isSystem && user.role === UserRole.ADMIN) ||
(isCanManageStorages && (
<>
<div className="mb-5 flex items-center text-2xl font-bold">
{storage.name}
{(!storage.isSystem || user.role === UserRole.ADMIN) && isCanManageStorages && (
<div className="ml-2 cursor-pointer" onClick={() => startEdit('name')}>
<img src="/icons/pen-gray.svg" />
</div>
))}
</div>
)}
</div>
{storage.isSystem && (
<span className="mt-2 inline-block rounded-xl bg-[#00000010] px-2 py-1 text-xs text-gray-700 dark:bg-[#ffffff10] dark:text-gray-300">
System storage
</span>
)}
</>
) : (
<div>
<div className="flex items-center">
@@ -220,19 +227,23 @@ export const StorageComponent = ({
</div>
)}
<div className="mt-5 flex items-center font-bold">
<div>Storage settings</div>
{!storage.isSystem ||
(user.role === UserRole.ADMIN && (
<div className="mt-5 flex items-center font-bold">
<div>Storage settings</div>
{!isEditSettings &&
isCanManageStorages &&
!(storage.isSystem && user.role !== UserRole.ADMIN) ? (
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('settings')}>
<img src="/icons/pen-gray.svg" />
{!isEditSettings && isCanManageStorages ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('settings')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
) : (
<div />
)}
</div>
))}
<div className="mt-1 text-sm">
{isEditSettings && isCanManageStorages ? (
@@ -254,7 +265,7 @@ export const StorageComponent = ({
)}
</div>
{!isEditSettings && (
{!isEditSettings && (!storage.isSystem || user.role === UserRole.ADMIN) && (
<div className="mt-5">
<Button
type="primary"

View File

@@ -18,6 +18,8 @@ interface Props {
export function ShowStorageComponent({ storage, user }: Props) {
if (!storage) return null;
if (storage?.isSystem && user.role !== UserRole.ADMIN) return <div />;
return (
<div>
<div className="mb-1 flex items-center">
@@ -39,33 +41,23 @@ export function ShowStorageComponent({ storage, user }: Props) {
</div>
)}
<div>{storage?.type === StorageType.S3 && <ShowS3StorageComponent storage={storage} />}</div>
<div>
{storage?.type === StorageType.S3 && <ShowS3StorageComponent storage={storage} />}
{storage?.type === StorageType.GOOGLE_DRIVE && (
<ShowGoogleDriveStorageComponent storage={storage} />
)}
</div>
<div>
{storage?.type === StorageType.NAS && <ShowNASStorageComponent storage={storage} />}
</div>
<div>
{storage?.type === StorageType.AZURE_BLOB && (
<ShowAzureBlobStorageComponent storage={storage} />
)}
</div>
<div>
{storage?.type === StorageType.FTP && <ShowFTPStorageComponent storage={storage} />}
</div>
<div>
{storage?.type === StorageType.SFTP && <ShowSFTPStorageComponent storage={storage} />}
</div>
<div>
{storage?.type === StorageType.RCLONE && <ShowRcloneStorageComponent storage={storage} />}
</div>
</div>

View File

@@ -1,5 +1,7 @@
export { AdminPasswordComponent } from './ui/AdminPasswordComponent';
export { AuthNavbarComponent } from './ui/AuthNavbarComponent';
export { ProfileComponent } from './ui/ProfileComponent';
export { RequestResetPasswordComponent } from './ui/RequestResetPasswordComponent';
export { ResetPasswordComponent } from './ui/ResetPasswordComponent';
export { SignInComponent } from './ui/SignInComponent';
export { SignUpComponent } from './ui/SignUpComponent';

View File

@@ -0,0 +1,123 @@
import { Button, Input } from 'antd';
import { type JSX, useState } from 'react';
import { userApi } from '../../../entity/users';
import { StringUtils } from '../../../shared/lib';
import { FormValidator } from '../../../shared/lib/FormValidator';
interface RequestResetPasswordComponentProps {
onSwitchToSignIn?: () => void;
onSwitchToResetPassword?: (email: string) => void;
}
export function RequestResetPasswordComponent({
onSwitchToSignIn,
onSwitchToResetPassword,
}: RequestResetPasswordComponentProps): JSX.Element {
const [email, setEmail] = useState('');
const [isLoading, setLoading] = useState(false);
const [isEmailError, setEmailError] = useState(false);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const validateEmail = (): boolean => {
if (!email) {
setEmailError(true);
return false;
}
if (!FormValidator.isValidEmail(email)) {
setEmailError(true);
return false;
}
return true;
};
const onSendCode = async () => {
setError('');
setSuccessMessage('');
if (validateEmail()) {
setLoading(true);
try {
const response = await userApi.sendResetPasswordCode({ email });
setSuccessMessage(response.message);
// After successful code send, switch to reset password form
setTimeout(() => {
if (onSwitchToResetPassword) {
onSwitchToResetPassword(email);
}
}, 2000);
} catch (e) {
setError(StringUtils.capitalizeFirstLetter((e as Error).message));
}
setLoading(false);
}
};
return (
<div className="w-full max-w-[300px]">
<div className="mb-5 text-center text-2xl font-bold">Reset password</div>
<div className="mb-4 text-center text-sm text-gray-600 dark:text-gray-400">
Enter your email address and we&apos;ll send you a reset code.
</div>
<div className="my-1 text-xs font-semibold">Your email</div>
<Input
placeholder="your@email.com"
value={email}
onChange={(e) => {
setEmailError(false);
setEmail(e.currentTarget.value.trim().toLowerCase());
}}
status={isEmailError ? 'error' : undefined}
type="email"
onPressEnter={() => {
onSendCode();
}}
/>
<div className="mt-3" />
<Button
disabled={isLoading}
loading={isLoading}
className="w-full"
onClick={() => {
onSendCode();
}}
type="primary"
>
Send reset code
</Button>
{error && (
<div className="mt-3 flex justify-center text-center text-sm text-red-600">{error}</div>
)}
{successMessage && (
<div className="mt-3 flex justify-center text-center text-sm text-green-600">
{successMessage}
</div>
)}
{onSwitchToSignIn && (
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
Remember your password?{' '}
<button
type="button"
onClick={onSwitchToSignIn}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Sign in
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,221 @@
import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
import { App, Button, Input } from 'antd';
import { type JSX, useState } from 'react';
import { userApi } from '../../../entity/users';
import { StringUtils } from '../../../shared/lib';
import { FormValidator } from '../../../shared/lib/FormValidator';
interface ResetPasswordComponentProps {
onSwitchToSignIn?: () => void;
onSwitchToRequestCode?: () => void;
initialEmail?: string;
}
export function ResetPasswordComponent({
onSwitchToSignIn,
onSwitchToRequestCode,
initialEmail = '',
}: ResetPasswordComponentProps): JSX.Element {
const { message } = App.useApp();
const [email, setEmail] = useState(initialEmail);
const [code, setCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false);
const [isLoading, setLoading] = useState(false);
const [isEmailError, setEmailError] = useState(false);
const [isCodeError, setCodeError] = useState(false);
const [passwordError, setPasswordError] = useState(false);
const [confirmPasswordError, setConfirmPasswordError] = useState(false);
const [error, setError] = useState('');
const validateFields = (): boolean => {
let isValid = true;
if (!email) {
setEmailError(true);
isValid = false;
} else if (!FormValidator.isValidEmail(email)) {
setEmailError(true);
isValid = false;
} else {
setEmailError(false);
}
if (!code) {
setCodeError(true);
isValid = false;
} else if (!/^\d{6}$/.test(code)) {
setCodeError(true);
message.error('Code must be 6 digits');
isValid = false;
} else {
setCodeError(false);
}
if (!newPassword) {
setPasswordError(true);
isValid = false;
} else if (newPassword.length < 8) {
setPasswordError(true);
message.error('Password must be at least 8 characters long');
isValid = false;
} else {
setPasswordError(false);
}
if (!confirmPassword) {
setConfirmPasswordError(true);
isValid = false;
} else if (newPassword !== confirmPassword) {
setConfirmPasswordError(true);
message.error('Passwords do not match');
isValid = false;
} else {
setConfirmPasswordError(false);
}
return isValid;
};
const onResetPassword = async () => {
setError('');
if (validateFields()) {
setLoading(true);
try {
await userApi.resetPassword({
email,
code,
newPassword,
});
message.success('Password reset successfully! Redirecting to sign in...');
// Redirect to sign in after successful reset
setTimeout(() => {
if (onSwitchToSignIn) {
onSwitchToSignIn();
}
}, 2000);
} catch (e) {
setError(StringUtils.capitalizeFirstLetter((e as Error).message));
}
setLoading(false);
}
};
return (
<div className="w-full max-w-[300px]">
<div className="mb-5 text-center text-2xl font-bold">Reset Password</div>
<div className="mb-4 text-center text-sm text-gray-600 dark:text-gray-400">
Enter the code sent to your email and your new password.
</div>
<div className="my-1 text-xs font-semibold">Your email</div>
<Input
placeholder="your@email.com"
value={email}
onChange={(e) => {
setEmailError(false);
setEmail(e.currentTarget.value.trim().toLowerCase());
}}
status={isEmailError ? 'error' : undefined}
type="email"
/>
<div className="my-1 text-xs font-semibold">Reset Code</div>
<Input
placeholder="123456"
value={code}
onChange={(e) => {
setCodeError(false);
const value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6);
setCode(value);
}}
status={isCodeError ? 'error' : undefined}
maxLength={6}
/>
<div className="my-1 text-xs font-semibold">New Password</div>
<Input.Password
placeholder="********"
value={newPassword}
onChange={(e) => {
setPasswordError(false);
setNewPassword(e.currentTarget.value);
}}
status={passwordError ? 'error' : undefined}
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
visibilityToggle={{ visible: passwordVisible, onVisibleChange: setPasswordVisible }}
/>
<div className="my-1 text-xs font-semibold">Confirm Password</div>
<Input.Password
placeholder="********"
value={confirmPassword}
status={confirmPasswordError ? 'error' : undefined}
onChange={(e) => {
setConfirmPasswordError(false);
setConfirmPassword(e.currentTarget.value);
}}
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
visibilityToggle={{
visible: confirmPasswordVisible,
onVisibleChange: setConfirmPasswordVisible,
}}
/>
<div className="mt-3" />
<Button
disabled={isLoading}
loading={isLoading}
className="w-full"
onClick={() => {
onResetPassword();
}}
type="primary"
>
Reset password
</Button>
{error && (
<div className="mt-3 flex justify-center text-center text-sm text-red-600">{error}</div>
)}
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
{onSwitchToRequestCode && (
<>
Didn&apos;t receive a code?{' '}
<button
type="button"
onClick={onSwitchToRequestCode}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Request new code
</button>
<br />
</>
)}
{onSwitchToSignIn && (
<button
type="button"
onClick={onSwitchToSignIn}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Back to sign in
</button>
)}
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { EyeInvisibleOutlined, EyeTwoTone } from '@ant-design/icons';
import { Button, Input } from 'antd';
import { type JSX, useState } from 'react';
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID } from '../../../constants';
import { GITHUB_CLIENT_ID, GOOGLE_CLIENT_ID, IS_EMAIL_CONFIGURED } from '../../../constants';
import { userApi } from '../../../entity/users';
import { StringUtils } from '../../../shared/lib';
import { FormValidator } from '../../../shared/lib/FormValidator';
@@ -11,9 +11,13 @@ import { GoogleOAuthComponent } from './oauth/GoogleOAuthComponent';
interface SignInComponentProps {
onSwitchToSignUp?: () => void;
onSwitchToResetPassword?: () => void;
}
export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX.Element {
export function SignInComponent({
onSwitchToSignUp,
onSwitchToResetPassword,
}: SignInComponentProps): JSX.Element {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
@@ -81,7 +85,9 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
<span className="bg-white px-2 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
or continue
</span>
</div>
</div>
)}
@@ -131,18 +137,26 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
</div>
)}
{onSwitchToSignUp && (
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
Don&apos;t have an account?{' '}
<div className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
Don&apos;t have an account?{' '}
<button
type="button"
onClick={onSwitchToSignUp}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Sign up
</button>
<br />
{IS_EMAIL_CONFIGURED && (
<button
type="button"
onClick={onSwitchToSignUp}
onClick={onSwitchToResetPassword}
className="cursor-pointer font-medium text-blue-600 hover:text-blue-700 dark:!text-blue-500"
>
Sign up
Forgot password?
</button>
</div>
)}
)}
</div>
</div>
);
}

View File

@@ -112,7 +112,9 @@ export function SignUpComponent({ onSwitchToSignIn }: SignUpComponentProps): JSX
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500 dark:text-gray-400">or continue</span>
<span className="bg-white px-2 text-gray-500 dark:bg-gray-900 dark:text-gray-400">
or continue
</span>
</div>
</div>
)}

View File

@@ -7,6 +7,8 @@ import { PlaygroundWarningComponent } from '../features/playground';
import {
AdminPasswordComponent,
AuthNavbarComponent,
RequestResetPasswordComponent,
ResetPasswordComponent,
SignInComponent,
SignUpComponent,
} from '../features/users';
@@ -14,7 +16,10 @@ import { useScreenHeight } from '../shared/hooks';
export function AuthPageComponent() {
const [isAdminHasPassword, setIsAdminHasPassword] = useState(false);
const [authMode, setAuthMode] = useState<'signIn' | 'signUp'>('signUp');
const [authMode, setAuthMode] = useState<'signIn' | 'signUp' | 'requestReset' | 'resetPassword'>(
'signUp',
);
const [resetEmail, setResetEmail] = useState('');
const [isLoading, setLoading] = useState(true);
const screenHeight = useScreenHeight();
@@ -51,8 +56,25 @@ export function AuthPageComponent() {
{isAdminHasPassword ? (
authMode === 'signUp' ? (
<SignUpComponent onSwitchToSignIn={() => setAuthMode('signIn')} />
) : authMode === 'signIn' ? (
<SignInComponent
onSwitchToSignUp={() => setAuthMode('signUp')}
onSwitchToResetPassword={() => setAuthMode('requestReset')}
/>
) : authMode === 'requestReset' ? (
<RequestResetPasswordComponent
onSwitchToSignIn={() => setAuthMode('signIn')}
onSwitchToResetPassword={(email) => {
setResetEmail(email);
setAuthMode('resetPassword');
}}
/>
) : (
<SignInComponent onSwitchToSignUp={() => setAuthMode('signUp')} />
<ResetPasswordComponent
onSwitchToSignIn={() => setAuthMode('signIn')}
onSwitchToRequestCode={() => setAuthMode('requestReset')}
initialEmail={resetEmail}
/>
)
) : (
<AdminPasswordComponent onPasswordSet={checkAdminPasswordStatus} />