From 105777ab6fe2385949397d79a3d712508ba4aae9 Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Wed, 28 Jan 2026 17:28:36 +0300 Subject: [PATCH] FEATURE (email): Add sending email about members invitation and password reset --- Dockerfile | 11 +- backend/internal/config/config.go | 9 + backend/internal/features/email/di.go | 22 + backend/internal/features/email/email.go | 244 +++++++ .../users/controllers/password_reset_test.go | 593 ++++++++++++++++++ .../users/controllers/user_controller.go | 71 +++ backend/internal/features/users/dto/dto.go | 10 + .../features/users/interfaces/interfaces.go | 4 + .../users/models/password_reset_code.go | 24 + .../features/users/repositories/di.go | 5 + .../repositories/password_reset_repository.go | 61 ++ .../internal/features/users/services/di.go | 3 + .../features/users/services/user_services.go | 176 +++++- .../internal/features/users/testing/mocks.go | 33 + .../workspaces/interfaces/interfaces.go | 4 + .../features/workspaces/services/di.go | 4 + .../workspaces/services/membership_service.go | 64 ++ .../features/workspaces/testing/mocks.go | 33 + ...0260128115419_add_password_reset_codes.sql | 31 + frontend/.env.development.example | 6 +- frontend/src/constants.ts | 6 +- frontend/src/entity/users/api/userApi.ts | 20 + frontend/src/entity/users/index.ts | 2 + .../users/model/ResetPasswordRequest.ts | 5 + .../model/SendResetPasswordCodeRequest.ts | 3 + frontend/src/features/users/index.ts | 2 + .../ui/RequestResetPasswordComponent.tsx | 123 ++++ .../users/ui/ResetPasswordComponent.tsx | 221 +++++++ .../src/features/users/ui/SignInComponent.tsx | 30 +- frontend/src/pages/AuthPageComponent.tsx | 26 +- 30 files changed, 1828 insertions(+), 18 deletions(-) create mode 100644 backend/internal/features/email/di.go create mode 100644 backend/internal/features/email/email.go create mode 100644 backend/internal/features/users/controllers/password_reset_test.go create mode 100644 backend/internal/features/users/models/password_reset_code.go create mode 100644 backend/internal/features/users/repositories/password_reset_repository.go create mode 100644 backend/internal/features/users/testing/mocks.go create mode 100644 backend/internal/features/workspaces/testing/mocks.go create mode 100644 backend/migrations/20260128115419_add_password_reset_codes.sql create mode 100644 frontend/src/entity/users/model/ResetPasswordRequest.ts create mode 100644 frontend/src/entity/users/model/SendResetPasswordCodeRequest.ts create mode 100644 frontend/src/features/users/ui/RequestResetPasswordComponent.tsx create mode 100644 frontend/src/features/users/ui/ResetPasswordComponent.tsx diff --git a/Dockerfile b/Dockerfile index d2daabe..8e09468 100644 --- a/Dockerfile +++ b/Dockerfile @@ -253,13 +253,22 @@ PG_BIN="/usr/lib/postgresql/17/bin" # Generate runtime configuration for frontend echo "Generating runtime configuration..." + +# 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 <CODE + // First find

after

' { + contentStart = i + 1 + break + } + } + + // Find

+ contentEnd := contentStart + for i := contentStart; i < len(emailBody)-5; i++ { + if emailBody[i:i+5] == "" { + 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' +} diff --git a/backend/internal/features/users/controllers/user_controller.go b/backend/internal/features/users/controllers/user_controller.go index 066e9b4..570f92d 100644 --- a/backend/internal/features/users/controllers/user_controller.go +++ b/backend/internal/features/users/controllers/user_controller.go @@ -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"}) +} diff --git a/backend/internal/features/users/dto/dto.go b/backend/internal/features/users/dto/dto.go index aab8b57..40b2a79 100644 --- a/backend/internal/features/users/dto/dto.go +++ b/backend/internal/features/users/dto/dto.go @@ -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"` +} diff --git a/backend/internal/features/users/interfaces/interfaces.go b/backend/internal/features/users/interfaces/interfaces.go index b30e885..07c9acd 100644 --- a/backend/internal/features/users/interfaces/interfaces.go +++ b/backend/internal/features/users/interfaces/interfaces.go @@ -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 +} diff --git a/backend/internal/features/users/models/password_reset_code.go b/backend/internal/features/users/models/password_reset_code.go new file mode 100644 index 0000000..0ea8772 --- /dev/null +++ b/backend/internal/features/users/models/password_reset_code.go @@ -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) +} diff --git a/backend/internal/features/users/repositories/di.go b/backend/internal/features/users/repositories/di.go index 386d9da..b4b4e6a 100644 --- a/backend/internal/features/users/repositories/di.go +++ b/backend/internal/features/users/repositories/di.go @@ -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 +} diff --git a/backend/internal/features/users/repositories/password_reset_repository.go b/backend/internal/features/users/repositories/password_reset_repository.go new file mode 100644 index 0000000..1c85104 --- /dev/null +++ b/backend/internal/features/users/repositories/password_reset_repository.go @@ -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 +} diff --git a/backend/internal/features/users/services/di.go b/backend/internal/features/users/services/di.go index c384c0c..43aa2b8 100644 --- a/backend/internal/features/users/services/di.go +++ b/backend/internal/features/users/services/di.go @@ -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(), diff --git a/backend/internal/features/users/services/user_services.go b/backend/internal/features/users/services/user_services.go index 530314a..8988dac 100644 --- a/backend/internal/features/users/services/user_services.go +++ b/backend/internal/features/users/services/user_services.go @@ -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(` + + + + + + + +
+

Password Reset Request

+

+ You have requested to reset your password. Please use the following code to complete the password reset process: +

+
+

%s

+
+

+ This code will expire in 1 hour. +

+

+ If you did not request a password reset, please ignore this email. Your password will remain unchanged. +

+
+

+ This is an automated message. Please do not reply to this email. +

+
+ + +`, 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 +} diff --git a/backend/internal/features/users/testing/mocks.go b/backend/internal/features/users/testing/mocks.go new file mode 100644 index 0000000..57fc27d --- /dev/null +++ b/backend/internal/features/users/testing/mocks.go @@ -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, + } +} diff --git a/backend/internal/features/workspaces/interfaces/interfaces.go b/backend/internal/features/workspaces/interfaces/interfaces.go index 20345d7..47fa883 100644 --- a/backend/internal/features/workspaces/interfaces/interfaces.go +++ b/backend/internal/features/workspaces/interfaces/interfaces.go @@ -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 +} diff --git a/backend/internal/features/workspaces/services/di.go b/backend/internal/features/workspaces/services/di.go index e9d6f9a..e03867c 100644 --- a/backend/internal/features/workspaces/services/di.go +++ b/backend/internal/features/workspaces/services/di.go @@ -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 { diff --git a/backend/internal/features/workspaces/services/membership_service.go b/backend/internal/features/workspaces/services/membership_service.go index 0c3e3f8..b47fbf2 100644 --- a/backend/internal/features/workspaces/services/membership_service.go +++ b/backend/internal/features/workspaces/services/membership_service.go @@ -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(`

+ + Sign up + +

`, env.DatabasusURL) + } else { + signUpLink = `

+ Please visit your Databasus instance to sign up and access the workspace. +

` + } + + return fmt.Sprintf(` + + + + + + + +
+

Workspace Invitation

+ +

+ %s has invited you to join the %s workspace as a %s. +

+ + %s + +
+ +

+ This is an automated message from Databasus. If you didn't expect this invitation, you can safely ignore this email. +

+
+ + + `, inviterName, workspaceName, role, signUpLink) +} diff --git a/backend/internal/features/workspaces/testing/mocks.go b/backend/internal/features/workspaces/testing/mocks.go new file mode 100644 index 0000000..b469bc1 --- /dev/null +++ b/backend/internal/features/workspaces/testing/mocks.go @@ -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, + } +} diff --git a/backend/migrations/20260128115419_add_password_reset_codes.sql b/backend/migrations/20260128115419_add_password_reset_codes.sql new file mode 100644 index 0000000..53b0ac3 --- /dev/null +++ b/backend/migrations/20260128115419_add_password_reset_codes.sql @@ -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 diff --git a/frontend/.env.development.example b/frontend/.env.development.example index b3ec4c9..5644f3f 100644 --- a/frontend/.env.development.example +++ b/frontend/.env.development.example @@ -1 +1,5 @@ -MODE=development \ No newline at end of file +MODE=development +VITE_GITHUB_CLIENT_ID= +VITE_GOOGLE_CLIENT_ID= +VITE_IS_EMAIL_CONFIGURED=false +VITE_IS_CLOUD=false \ No newline at end of file diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts index a2461e1..0399dac 100644 --- a/frontend/src/constants.ts +++ b/frontend/src/constants.ts @@ -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`; } diff --git a/frontend/src/entity/users/api/userApi.ts b/frontend/src/entity/users/api/userApi.ts index 39738f1..4f20419 100644 --- a/frontend/src/entity/users/api/userApi.ts +++ b/frontend/src/entity/users/api/userApi.ts @@ -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: () => { diff --git a/frontend/src/entity/users/index.ts b/frontend/src/entity/users/index.ts index d443267..fd8a7f3 100644 --- a/frontend/src/entity/users/index.ts +++ b/frontend/src/entity/users/index.ts @@ -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'; diff --git a/frontend/src/entity/users/model/ResetPasswordRequest.ts b/frontend/src/entity/users/model/ResetPasswordRequest.ts new file mode 100644 index 0000000..0295498 --- /dev/null +++ b/frontend/src/entity/users/model/ResetPasswordRequest.ts @@ -0,0 +1,5 @@ +export interface ResetPasswordRequest { + email: string; + code: string; + newPassword: string; +} diff --git a/frontend/src/entity/users/model/SendResetPasswordCodeRequest.ts b/frontend/src/entity/users/model/SendResetPasswordCodeRequest.ts new file mode 100644 index 0000000..cfe649b --- /dev/null +++ b/frontend/src/entity/users/model/SendResetPasswordCodeRequest.ts @@ -0,0 +1,3 @@ +export interface SendResetPasswordCodeRequest { + email: string; +} diff --git a/frontend/src/features/users/index.ts b/frontend/src/features/users/index.ts index fb81796..e86e1b7 100644 --- a/frontend/src/features/users/index.ts +++ b/frontend/src/features/users/index.ts @@ -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'; diff --git a/frontend/src/features/users/ui/RequestResetPasswordComponent.tsx b/frontend/src/features/users/ui/RequestResetPasswordComponent.tsx new file mode 100644 index 0000000..f0a434c --- /dev/null +++ b/frontend/src/features/users/ui/RequestResetPasswordComponent.tsx @@ -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 ( +
+
Reset password
+ +
+ Enter your email address and we'll send you a reset code. +
+ +
Your email
+ { + setEmailError(false); + setEmail(e.currentTarget.value.trim().toLowerCase()); + }} + status={isEmailError ? 'error' : undefined} + type="email" + onPressEnter={() => { + onSendCode(); + }} + /> + +
+ + + + {error && ( +
{error}
+ )} + + {successMessage && ( +
+ {successMessage} +
+ )} + + {onSwitchToSignIn && ( +
+ Remember your password?{' '} + +
+ )} +
+ ); +} diff --git a/frontend/src/features/users/ui/ResetPasswordComponent.tsx b/frontend/src/features/users/ui/ResetPasswordComponent.tsx new file mode 100644 index 0000000..88cfdac --- /dev/null +++ b/frontend/src/features/users/ui/ResetPasswordComponent.tsx @@ -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 ( +
+
Reset Password
+ +
+ Enter the code sent to your email and your new password. +
+ +
Your email
+ { + setEmailError(false); + setEmail(e.currentTarget.value.trim().toLowerCase()); + }} + status={isEmailError ? 'error' : undefined} + type="email" + /> + +
Reset Code
+ { + setCodeError(false); + const value = e.currentTarget.value.replace(/\D/g, '').slice(0, 6); + setCode(value); + }} + status={isCodeError ? 'error' : undefined} + maxLength={6} + /> + +
New Password
+ { + setPasswordError(false); + setNewPassword(e.currentTarget.value); + }} + status={passwordError ? 'error' : undefined} + iconRender={(visible) => (visible ? : )} + visibilityToggle={{ visible: passwordVisible, onVisibleChange: setPasswordVisible }} + /> + +
Confirm Password
+ { + setConfirmPasswordError(false); + setConfirmPassword(e.currentTarget.value); + }} + iconRender={(visible) => (visible ? : )} + visibilityToggle={{ + visible: confirmPasswordVisible, + onVisibleChange: setConfirmPasswordVisible, + }} + /> + +
+ + + + {error && ( +
{error}
+ )} + +
+ {onSwitchToRequestCode && ( + <> + Didn't receive a code?{' '} + +
+ + )} + {onSwitchToSignIn && ( + + )} +
+
+ ); +} diff --git a/frontend/src/features/users/ui/SignInComponent.tsx b/frontend/src/features/users/ui/SignInComponent.tsx index 7abf6a5..0304d69 100644 --- a/frontend/src/features/users/ui/SignInComponent.tsx +++ b/frontend/src/features/users/ui/SignInComponent.tsx @@ -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); @@ -133,18 +137,26 @@ export function SignInComponent({ onSwitchToSignUp }: SignInComponentProps): JSX
)} - {onSwitchToSignUp && ( -
- Don't have an account?{' '} +
+ Don't have an account?{' '} + +
+ {IS_EMAIL_CONFIGURED && ( -
- )} + )} +
); } diff --git a/frontend/src/pages/AuthPageComponent.tsx b/frontend/src/pages/AuthPageComponent.tsx index 173b5c8..a3f1a59 100644 --- a/frontend/src/pages/AuthPageComponent.tsx +++ b/frontend/src/pages/AuthPageComponent.tsx @@ -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' ? ( setAuthMode('signIn')} /> + ) : authMode === 'signIn' ? ( + setAuthMode('signUp')} + onSwitchToResetPassword={() => setAuthMode('requestReset')} + /> + ) : authMode === 'requestReset' ? ( + setAuthMode('signIn')} + onSwitchToResetPassword={(email) => { + setResetEmail(email); + setAuthMode('resetPassword'); + }} + /> ) : ( - setAuthMode('signUp')} /> + setAuthMode('signIn')} + onSwitchToRequestCode={() => setAuthMode('requestReset')} + initialEmail={resetEmail} + /> ) ) : (