diff --git a/cloud/maplefile-backend/app/wire.go b/cloud/maplefile-backend/app/wire.go index f3dc201..bf08b2a 100644 --- a/cloud/maplefile-backend/app/wire.go +++ b/cloud/maplefile-backend/app/wire.go @@ -213,10 +213,12 @@ func InitializeApplication(cfg *config.Configuration) (*Application, error) { svc_auth.ProvideRecoveryVerifyService, svc_auth.ProvideRecoveryCompleteService, - // Service layer - Me (3 providers) + // Service layer - Me (5 providers) svc_me.ProvideGetMeService, svc_me.ProvideUpdateMeService, svc_me.ProvideDeleteMeService, + svc_me.ProvideChangeEmailRequestService, + svc_me.ProvideChangeEmailVerifyService, // Service layer - Dashboard (1 provider) svc_dashboard.ProvideGetDashboardService, @@ -251,10 +253,12 @@ func InitializeApplication(cfg *config.Configuration) (*Application, error) { // HTTP handlers - Dashboard dashboard.ProvideGetDashboardHTTPHandler, - // HTTP handlers - Me + // HTTP handlers - Me (5 providers) me.ProvideGetMeHTTPHandler, me.ProvidePutUpdateMeHTTPHandler, me.ProvideDeleteMeHTTPHandler, + me.ProvidePostChangeEmailRequestHTTPHandler, + me.ProvidePostChangeEmailVerifyHTTPHandler, // HTTP handlers - User (1 provider) user.ProvideUserPublicLookupHTTPHandler, diff --git a/cloud/maplefile-backend/app/wire_gen.go b/cloud/maplefile-backend/app/wire_gen.go index cf67962..cf7d683 100644 --- a/cloud/maplefile-backend/app/wire_gen.go +++ b/cloud/maplefile-backend/app/wire_gen.go @@ -126,6 +126,12 @@ func InitializeApplication(cfg *config.Config) (*Application, error) { completeUserDeletionService := user3.ProvideCompleteUserDeletionService(cfg, zapLogger, userGetByIDUseCase, userDeleteByIDUseCase, listFilesByOwnerIDService, softDeleteFileService, listCollectionsByUserUseCase, softDeleteCollectionService, removeUserFromAllCollectionsUseCase, deleteByUserUseCase, storageusageeventDeleteByUserUseCase, anonymizeUserIPsImmediatelyUseCase, clearUserCacheUseCase, anonymizeUserReferencesUseCase, collectionAnonymizeUserReferencesUseCase) deleteMeService := me.ProvideDeleteMeService(cfg, zapLogger, completeUserDeletionService) deleteMeHTTPHandler := me2.ProvideDeleteMeHTTPHandler(cfg, zapLogger, deleteMeService, middlewareMiddleware) + auditLogger := auditlog.ProvideAuditLogger(zapLogger) + emailer := mailgun.ProvideMapleFileModuleEmailer(cfg) + changeEmailRequestService := me.ProvideChangeEmailRequestService(cfg, zapLogger, auditLogger, userGetByIDUseCase, userGetByEmailUseCase, userUpdateUseCase, emailer) + postChangeEmailRequestHTTPHandler := me2.ProvidePostChangeEmailRequestHTTPHandler(cfg, zapLogger, changeEmailRequestService, middlewareMiddleware) + changeEmailVerifyService := me.ProvideChangeEmailVerifyService(cfg, zapLogger, auditLogger, userGetByIDUseCase, userUpdateUseCase, emailer) + postChangeEmailVerifyHTTPHandler := me2.ProvidePostChangeEmailVerifyHTTPHandler(cfg, zapLogger, changeEmailVerifyService, middlewareMiddleware) userPublicLookupService := user3.ProvideUserPublicLookupService(cfg, zapLogger, userGetByEmailUseCase) userPublicLookupHTTPHandler := user4.ProvideUserPublicLookupHTTPHandler(cfg, zapLogger, userPublicLookupService, middlewareMiddleware) blockedEmailRepository := blockedemail.NewBlockedEmailRepository(cfg, zapLogger, session) @@ -139,7 +145,6 @@ func InitializeApplication(cfg *config.Config) (*Application, error) { deleteBlockedEmailService := blockedemail3.ProvideDeleteBlockedEmailService(cfg, zapLogger, deleteBlockedEmailUseCase) deleteBlockedEmailHTTPHandler := blockedemail4.ProvideDeleteBlockedEmailHTTPHandler(cfg, zapLogger, deleteBlockedEmailService, middlewareMiddleware) inviteemailratelimitRepository := inviteemailratelimit.ProvideRepository(cfg, session, zapLogger) - emailer := mailgun.ProvideMapleFileModuleEmailer(cfg) sendInviteEmailService := inviteemail.ProvideSendInviteEmailService(cfg, zapLogger, repository, inviteemailratelimitRepository, emailer) sendInviteEmailHTTPHandler := inviteemail2.ProvideSendInviteEmailHTTPHandler(cfg, zapLogger, sendInviteEmailService, middlewareMiddleware) tagRepository := tag.ProvideTagRepository(session) @@ -239,8 +244,7 @@ func InitializeApplication(cfg *config.Config) (*Application, error) { listFilesByTagHandler := tag4.ProvideListFilesByTagHandler(listFilesByTagUseCase, zapLogger) searchByTagsService := tag3.ProvideSearchByTagsService(zapLogger, listCollectionsByTagUseCase, listFilesByTagUseCase) searchByTagsHandler := tag4.ProvideSearchByTagsHandler(searchByTagsService, zapLogger, middlewareMiddleware) - handlers := http.ProvideHandlers(cfg, zapLogger, mapleFileVersionHTTPHandler, getDashboardHTTPHandler, getMeHTTPHandler, putUpdateMeHTTPHandler, deleteMeHTTPHandler, userPublicLookupHTTPHandler, createBlockedEmailHTTPHandler, listBlockedEmailsHTTPHandler, deleteBlockedEmailHTTPHandler, sendInviteEmailHTTPHandler, createCollectionHTTPHandler, getCollectionHTTPHandler, listUserCollectionsHTTPHandler, updateCollectionHTTPHandler, softDeleteCollectionHTTPHandler, archiveCollectionHTTPHandler, restoreCollectionHTTPHandler, findCollectionsByParentHTTPHandler, findRootCollectionsHTTPHandler, moveCollectionHTTPHandler, shareCollectionHTTPHandler, removeMemberHTTPHandler, listSharedCollectionsHTTPHandler, getFilteredCollectionsHTTPHandler, collectionSyncHTTPHandler, softDeleteFileHTTPHandler, deleteMultipleFilesHTTPHandler, getFileHTTPHandler, listFilesByCollectionHTTPHandler, updateFileHTTPHandler, createPendingFileHTTPHandler, completeFileUploadHTTPHandler, getPresignedUploadURLHTTPHandler, getPresignedDownloadURLHTTPHandler, reportDownloadCompletedHTTPHandler, archiveFileHTTPHandler, restoreFileHTTPHandler, listRecentFilesHTTPHandler, fileSyncHTTPHandler, createTagHTTPHandler, listTagsHTTPHandler, getTagHTTPHandler, updateTagHTTPHandler, deleteTagHTTPHandler, assignTagHTTPHandler, unassignTagHTTPHandler, getTagsForCollectionHTTPHandler, getTagsForFileHTTPHandler, listCollectionsByTagHandler, listFilesByTagHandler, searchByTagsHandler) - auditLogger := auditlog.ProvideAuditLogger(zapLogger) + handlers := http.ProvideHandlers(cfg, zapLogger, mapleFileVersionHTTPHandler, getDashboardHTTPHandler, getMeHTTPHandler, putUpdateMeHTTPHandler, deleteMeHTTPHandler, postChangeEmailRequestHTTPHandler, postChangeEmailVerifyHTTPHandler, userPublicLookupHTTPHandler, createBlockedEmailHTTPHandler, listBlockedEmailsHTTPHandler, deleteBlockedEmailHTTPHandler, sendInviteEmailHTTPHandler, createCollectionHTTPHandler, getCollectionHTTPHandler, listUserCollectionsHTTPHandler, updateCollectionHTTPHandler, softDeleteCollectionHTTPHandler, archiveCollectionHTTPHandler, restoreCollectionHTTPHandler, findCollectionsByParentHTTPHandler, findRootCollectionsHTTPHandler, moveCollectionHTTPHandler, shareCollectionHTTPHandler, removeMemberHTTPHandler, listSharedCollectionsHTTPHandler, getFilteredCollectionsHTTPHandler, collectionSyncHTTPHandler, softDeleteFileHTTPHandler, deleteMultipleFilesHTTPHandler, getFileHTTPHandler, listFilesByCollectionHTTPHandler, updateFileHTTPHandler, createPendingFileHTTPHandler, completeFileUploadHTTPHandler, getPresignedUploadURLHTTPHandler, getPresignedDownloadURLHTTPHandler, reportDownloadCompletedHTTPHandler, archiveFileHTTPHandler, restoreFileHTTPHandler, listRecentFilesHTTPHandler, fileSyncHTTPHandler, createTagHTTPHandler, listTagsHTTPHandler, getTagHTTPHandler, updateTagHTTPHandler, deleteTagHTTPHandler, assignTagHTTPHandler, unassignTagHTTPHandler, getTagsForCollectionHTTPHandler, getTagsForFileHTTPHandler, listCollectionsByTagHandler, listFilesByTagHandler, searchByTagsHandler) registerService := auth.ProvideRegisterService(cfg, zapLogger, auditLogger, userCreateUseCase, userGetByEmailUseCase, userDeleteByIDUseCase, emailer) userGetByVerificationCodeUseCase := user2.ProvideUserGetByVerificationCodeUseCase(cfg, zapLogger, repository) verifyEmailService := auth.ProvideVerifyEmailService(zapLogger, auditLogger, userGetByVerificationCodeUseCase, userUpdateUseCase) diff --git a/cloud/maplefile-backend/internal/domain/user/model.go b/cloud/maplefile-backend/internal/domain/user/model.go index baf83f6..fc899ae 100644 --- a/cloud/maplefile-backend/internal/domain/user/model.go +++ b/cloud/maplefile-backend/internal/domain/user/model.go @@ -121,6 +121,11 @@ type UserSecurityData struct { // OTPBackupCodeHashAlgorithm tracks the hashing algorithm used. OTPBackupCodeHashAlgorithm string `bson:"otp_backup_code_hash_algorithm" json:"-"` + + // Email change verification fields + PendingEmail string `bson:"pending_email,omitempty" json:"pending_email,omitempty"` // New email pending verification + PendingEmailCode string `bson:"pending_email_code,omitempty" json:"pending_email_code,omitempty"` // Verification code for new email (sensitive, stored internally but never exposed in API responses) + PendingEmailExpiry time.Time `bson:"pending_email_expiry,omitempty" json:"pending_email_expiry,omitempty"` // When verification expires } type UserMetadata struct { diff --git a/cloud/maplefile-backend/internal/interface/http/handlers.go b/cloud/maplefile-backend/internal/interface/http/handlers.go index 1748124..626731a 100644 --- a/cloud/maplefile-backend/internal/interface/http/handlers.go +++ b/cloud/maplefile-backend/internal/interface/http/handlers.go @@ -21,9 +21,11 @@ type Handlers struct { GetDashboard *dashboard.GetDashboardHTTPHandler // Me handlers - GetMe *me.GetMeHTTPHandler - UpdateMe *me.PutUpdateMeHTTPHandler - DeleteMe *me.DeleteMeHTTPHandler + GetMe *me.GetMeHTTPHandler + UpdateMe *me.PutUpdateMeHTTPHandler + DeleteMe *me.DeleteMeHTTPHandler + ChangeEmailRequest *me.PostChangeEmailRequestHTTPHandler + ChangeEmailVerify *me.PostChangeEmailVerifyHTTPHandler // User handlers UserPublicLookup *user.UserPublicLookupHTTPHandler @@ -106,6 +108,8 @@ func NewHandlers( getMe *me.GetMeHTTPHandler, updateMe *me.PutUpdateMeHTTPHandler, deleteMe *me.DeleteMeHTTPHandler, + changeEmailRequest *me.PostChangeEmailRequestHTTPHandler, + changeEmailVerify *me.PostChangeEmailVerifyHTTPHandler, // User userPublicLookup *user.UserPublicLookupHTTPHandler, @@ -183,9 +187,11 @@ func NewHandlers( GetDashboard: getDashboard, // Me - GetMe: getMe, - UpdateMe: updateMe, - DeleteMe: deleteMe, + GetMe: getMe, + UpdateMe: updateMe, + DeleteMe: deleteMe, + ChangeEmailRequest: changeEmailRequest, + ChangeEmailVerify: changeEmailVerify, // User UserPublicLookup: userPublicLookup, diff --git a/cloud/maplefile-backend/internal/interface/http/me/changeemail_request.go b/cloud/maplefile-backend/internal/interface/http/me/changeemail_request.go new file mode 100644 index 0000000..f749ae4 --- /dev/null +++ b/cloud/maplefile-backend/internal/interface/http/me/changeemail_request.go @@ -0,0 +1,106 @@ +// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/me/changeemail_request.go +package me + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + + "go.uber.org/zap" + + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware" + svc_me "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" +) + +type PostChangeEmailRequestHTTPHandler struct { + config *config.Configuration + logger *zap.Logger + service svc_me.ChangeEmailRequestService + middleware middleware.Middleware +} + +func NewPostChangeEmailRequestHTTPHandler( + config *config.Configuration, + logger *zap.Logger, + service svc_me.ChangeEmailRequestService, + middleware middleware.Middleware, +) *PostChangeEmailRequestHTTPHandler { + logger = logger.With(zap.String("module", "maplefile")) + logger = logger.Named("PostChangeEmailRequestHTTPHandler") + return &PostChangeEmailRequestHTTPHandler{ + config: config, + logger: logger, + service: service, + middleware: middleware, + } +} + +func (*PostChangeEmailRequestHTTPHandler) Pattern() string { + return "POST /api/v1/me/email/change-request" +} + +func (r *PostChangeEmailRequestHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // Apply middleware before handling the request + r.middleware.Attach(r.Execute)(w, req) +} + +func (h *PostChangeEmailRequestHTTPHandler) unmarshalRequest( + ctx context.Context, + r *http.Request, +) (*svc_me.ChangeEmailRequestDTO, error) { + var requestData svc_me.ChangeEmailRequestDTO + + defer r.Body.Close() + + var rawJSON bytes.Buffer + teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it + + err := json.NewDecoder(teeReader).Decode(&requestData) + if err != nil { + h.logger.Error("decoding error", + zap.Any("err", err), + zap.String("json", rawJSON.String()), + ) + return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong") + } + + return &requestData, nil +} + +func (h *PostChangeEmailRequestHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) { + // Set response content type + w.Header().Set("Content-Type", "application/json") + + ctx := r.Context() + + req, err := h.unmarshalRequest(ctx, r) + if err != nil { + httperror.RespondWithError(w, r, err) + return + } + + resp, err := h.service.Execute(ctx, req) + if err != nil { + httperror.RespondWithError(w, r, err) + return + } + + // Encode response + if resp != nil { + if err := json.NewEncoder(w).Encode(resp); err != nil { + h.logger.Error("failed to encode response", + zap.Any("error", err)) + httperror.RespondWithError(w, r, err) + return + } + } else { + err := errors.New("no result") + httperror.RespondWithError(w, r, err) + return + } +} diff --git a/cloud/maplefile-backend/internal/interface/http/me/changeemail_verify.go b/cloud/maplefile-backend/internal/interface/http/me/changeemail_verify.go new file mode 100644 index 0000000..2fc48b3 --- /dev/null +++ b/cloud/maplefile-backend/internal/interface/http/me/changeemail_verify.go @@ -0,0 +1,106 @@ +// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/me/changeemail_verify.go +package me + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + + "go.uber.org/zap" + + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/interface/http/middleware" + svc_me "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" +) + +type PostChangeEmailVerifyHTTPHandler struct { + config *config.Configuration + logger *zap.Logger + service svc_me.ChangeEmailVerifyService + middleware middleware.Middleware +} + +func NewPostChangeEmailVerifyHTTPHandler( + config *config.Configuration, + logger *zap.Logger, + service svc_me.ChangeEmailVerifyService, + middleware middleware.Middleware, +) *PostChangeEmailVerifyHTTPHandler { + logger = logger.With(zap.String("module", "maplefile")) + logger = logger.Named("PostChangeEmailVerifyHTTPHandler") + return &PostChangeEmailVerifyHTTPHandler{ + config: config, + logger: logger, + service: service, + middleware: middleware, + } +} + +func (*PostChangeEmailVerifyHTTPHandler) Pattern() string { + return "POST /api/v1/me/email/change-verify" +} + +func (r *PostChangeEmailVerifyHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + // Apply middleware before handling the request + r.middleware.Attach(r.Execute)(w, req) +} + +func (h *PostChangeEmailVerifyHTTPHandler) unmarshalRequest( + ctx context.Context, + r *http.Request, +) (*svc_me.ChangeEmailVerifyRequestDTO, error) { + var requestData svc_me.ChangeEmailVerifyRequestDTO + + defer r.Body.Close() + + var rawJSON bytes.Buffer + teeReader := io.TeeReader(r.Body, &rawJSON) // TeeReader allows you to read the JSON and capture it + + err := json.NewDecoder(teeReader).Decode(&requestData) + if err != nil { + h.logger.Error("decoding error", + zap.Any("err", err), + zap.String("json", rawJSON.String()), + ) + return nil, httperror.NewForSingleField(http.StatusBadRequest, "non_field_error", "payload structure is wrong") + } + + return &requestData, nil +} + +func (h *PostChangeEmailVerifyHTTPHandler) Execute(w http.ResponseWriter, r *http.Request) { + // Set response content type + w.Header().Set("Content-Type", "application/json") + + ctx := r.Context() + + req, err := h.unmarshalRequest(ctx, r) + if err != nil { + httperror.RespondWithError(w, r, err) + return + } + + resp, err := h.service.Execute(ctx, req) + if err != nil { + httperror.RespondWithError(w, r, err) + return + } + + // Encode response + if resp != nil { + if err := json.NewEncoder(w).Encode(resp); err != nil { + h.logger.Error("failed to encode response", + zap.Any("error", err)) + httperror.RespondWithError(w, r, err) + return + } + } else { + err := errors.New("no result") + httperror.RespondWithError(w, r, err) + return + } +} diff --git a/cloud/maplefile-backend/internal/interface/http/me/provider.go b/cloud/maplefile-backend/internal/interface/http/me/provider.go index 56700f9..f82c2c5 100644 --- a/cloud/maplefile-backend/internal/interface/http/me/provider.go +++ b/cloud/maplefile-backend/internal/interface/http/me/provider.go @@ -36,3 +36,21 @@ func ProvideDeleteMeHTTPHandler( ) *DeleteMeHTTPHandler { return NewDeleteMeHTTPHandler(cfg, logger, service, mw) } + +func ProvidePostChangeEmailRequestHTTPHandler( + cfg *config.Configuration, + logger *zap.Logger, + service svc_me.ChangeEmailRequestService, + mw middleware.Middleware, +) *PostChangeEmailRequestHTTPHandler { + return NewPostChangeEmailRequestHTTPHandler(cfg, logger, service, mw) +} + +func ProvidePostChangeEmailVerifyHTTPHandler( + cfg *config.Configuration, + logger *zap.Logger, + service svc_me.ChangeEmailVerifyService, + mw middleware.Middleware, +) *PostChangeEmailVerifyHTTPHandler { + return NewPostChangeEmailVerifyHTTPHandler(cfg, logger, service, mw) +} diff --git a/cloud/maplefile-backend/internal/interface/http/middleware/utils.go b/cloud/maplefile-backend/internal/interface/http/middleware/utils.go index 8b8de61..20ca52a 100644 --- a/cloud/maplefile-backend/internal/interface/http/middleware/utils.go +++ b/cloud/maplefile-backend/internal/interface/http/middleware/utils.go @@ -20,10 +20,12 @@ var ( func init() { // Exact matches exactPaths = map[string]bool{ - "/api/v1/me": true, - "/api/v1/me/delete": true, - "/api/v1/me/blocked-emails": true, - "/api/v1/dashboard": true, + "/api/v1/me": true, + "/api/v1/me/delete": true, + "/api/v1/me/blocked-emails": true, + "/api/v1/me/email/change-request": true, // Email change step 1: request + "/api/v1/me/email/change-verify": true, // Email change step 2: verify + "/api/v1/dashboard": true, "/api/v1/collections": true, "/api/v1/collections/filtered": true, "/api/v1/collections/root": true, diff --git a/cloud/maplefile-backend/internal/interface/http/provider.go b/cloud/maplefile-backend/internal/interface/http/provider.go index aa393c6..18d8199 100644 --- a/cloud/maplefile-backend/internal/interface/http/provider.go +++ b/cloud/maplefile-backend/internal/interface/http/provider.go @@ -32,6 +32,8 @@ func ProvideHandlers( getMe *me.GetMeHTTPHandler, updateMe *me.PutUpdateMeHTTPHandler, deleteMe *me.DeleteMeHTTPHandler, + changeEmailRequest *me.PostChangeEmailRequestHTTPHandler, + changeEmailVerify *me.PostChangeEmailVerifyHTTPHandler, // User userPublicLookup *user.UserPublicLookupHTTPHandler, @@ -112,6 +114,8 @@ func ProvideHandlers( getMe, updateMe, deleteMe, + changeEmailRequest, + changeEmailVerify, // User userPublicLookup, diff --git a/cloud/maplefile-backend/internal/interface/http/server.go b/cloud/maplefile-backend/internal/interface/http/server.go index 150c526..040c328 100644 --- a/cloud/maplefile-backend/internal/interface/http/server.go +++ b/cloud/maplefile-backend/internal/interface/http/server.go @@ -110,6 +110,8 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("GET /api/v1/me", s.handlers.GetMe.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/me", s.handlers.UpdateMe.ServeHTTP) s.mux.HandleFunc("DELETE /api/v1/me", s.handlers.DeleteMe.ServeHTTP) + s.mux.HandleFunc("POST /api/v1/me/email/change-request", s.handlers.ChangeEmailRequest.ServeHTTP) + s.mux.HandleFunc("POST /api/v1/me/email/change-verify", s.handlers.ChangeEmailVerify.ServeHTTP) // Blocked Email routes s.mux.HandleFunc("POST /api/v1/me/blocked-emails", s.handlers.CreateBlockedEmail.ServeHTTP) diff --git a/cloud/maplefile-backend/internal/interface/http/wire_server.go b/cloud/maplefile-backend/internal/interface/http/wire_server.go index cc146a1..2c4c756 100644 --- a/cloud/maplefile-backend/internal/interface/http/wire_server.go +++ b/cloud/maplefile-backend/internal/interface/http/wire_server.go @@ -160,6 +160,8 @@ func (s *WireServer) registerRoutes() { s.mux.HandleFunc("GET /api/v1/me", s.handlers.GetMe.ServeHTTP) s.mux.HandleFunc("PUT /api/v1/me", s.handlers.UpdateMe.ServeHTTP) s.mux.HandleFunc("DELETE /api/v1/me", s.handlers.DeleteMe.ServeHTTP) + s.mux.HandleFunc("POST /api/v1/me/email/change-request", s.handlers.ChangeEmailRequest.ServeHTTP) + s.mux.HandleFunc("POST /api/v1/me/email/change-verify", s.handlers.ChangeEmailVerify.ServeHTTP) // Blocked Email routes s.mux.HandleFunc("POST /api/v1/me/blocked-emails", s.handlers.CreateBlockedEmail.ServeHTTP) diff --git a/cloud/maplefile-backend/internal/service/me/change_email_request.go b/cloud/maplefile-backend/internal/service/me/change_email_request.go new file mode 100644 index 0000000..d1f4ac6 --- /dev/null +++ b/cloud/maplefile-backend/internal/service/me/change_email_request.go @@ -0,0 +1,304 @@ +// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me/change_email_request.go +package me + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "html" + "net/mail" + "strings" + "time" + + "github.com/awnumar/memguard" + "github.com/gocql/gocql" + "go.uber.org/zap" + + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants" + uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/auditlog" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/emailer/mailgun" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" +) + +type ChangeEmailRequestDTO struct { + NewEmail string `json:"new_email"` +} + +type ChangeEmailRequestResponseDTO struct { + Message string `json:"message"` + Success bool `json:"success"` +} + +type ChangeEmailRequestService interface { + Execute(ctx context.Context, req *ChangeEmailRequestDTO) (*ChangeEmailRequestResponseDTO, error) +} + +type changeEmailRequestServiceImpl struct { + config *config.Configuration + logger *zap.Logger + auditLogger auditlog.AuditLogger + userGetByIDUseCase uc_user.UserGetByIDUseCase + userGetByEmailUseCase uc_user.UserGetByEmailUseCase + userUpdateUseCase uc_user.UserUpdateUseCase + emailer mailgun.Emailer +} + +func NewChangeEmailRequestService( + config *config.Configuration, + logger *zap.Logger, + auditLogger auditlog.AuditLogger, + userGetByIDUseCase uc_user.UserGetByIDUseCase, + userGetByEmailUseCase uc_user.UserGetByEmailUseCase, + userUpdateUseCase uc_user.UserUpdateUseCase, + emailer mailgun.Emailer, +) ChangeEmailRequestService { + logger = logger.Named("ChangeEmailRequestService") + + return &changeEmailRequestServiceImpl{ + config: config, + logger: logger, + auditLogger: auditLogger, + userGetByIDUseCase: userGetByIDUseCase, + userGetByEmailUseCase: userGetByEmailUseCase, + userUpdateUseCase: userUpdateUseCase, + emailer: emailer, + } +} + +func (svc *changeEmailRequestServiceImpl) Execute(ctx context.Context, req *ChangeEmailRequestDTO) (*ChangeEmailRequestResponseDTO, error) { + // + // Get user from context + // + + userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID) + if !ok { + svc.logger.Error("Failed getting user id from context") + return nil, errors.New("user id not found in context") + } + + // + // Validation + // + + if req == nil { + svc.logger.Warn("Request is nil") + return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Request is required") + } + + // Sanitize and validate new email + req.NewEmail = strings.ToLower(strings.TrimSpace(req.NewEmail)) + + e := make(map[string]string) + if req.NewEmail == "" { + e["new_email"] = "New email address is required" + } else { + // Validate email format + if _, err := mail.ParseAddress(req.NewEmail); err != nil { + e["new_email"] = "Please enter a valid email address" + } + } + + if len(e) != 0 { + svc.logger.Warn("Validation failed", zap.Any("errors", e)) + return nil, httperror.NewForBadRequest(&e) + } + + // + // Get current user + // + + user, err := svc.userGetByIDUseCase.Execute(ctx, userID) + if err != nil { + svc.logger.Error("Failed getting user by ID", zap.Error(err)) + return nil, err + } + if user == nil { + err := fmt.Errorf("user is nil after lookup for id: %v", userID.String()) + svc.logger.Error("User not found", zap.Error(err)) + return nil, err + } + + // + // Check if new email is same as current + // + + if req.NewEmail == user.Email { + e["new_email"] = "New email is the same as your current email" + return nil, httperror.NewForBadRequest(&e) + } + + // + // Check if new email is already in use + // + + existingUser, err := svc.userGetByEmailUseCase.Execute(ctx, req.NewEmail) + if err != nil { + svc.logger.Error("Failed checking if email exists", + zap.String("email", validation.MaskEmail(req.NewEmail)), + zap.Error(err)) + return nil, err + } + if existingUser != nil { + svc.logger.Warn("Attempted to change to email already in use", + zap.String("user_id", userID.String()), + zap.String("existing_user_id", existingUser.ID.String()), + zap.String("new_email", validation.MaskEmail(req.NewEmail))) + e["new_email"] = "This email address is already in use" + return nil, httperror.NewForBadRequest(&e) + } + + // + // Generate verification code + // + + verificationCode := svc.generateVerificationCode() + verificationExpiry := time.Now().Add(24 * time.Hour) + + svc.logger.Debug("Generated verification code", + zap.String("user_id", user.ID.String()), + zap.String("code", verificationCode), + zap.Int("code_length", len(verificationCode)), + zap.String("code_bytes", fmt.Sprintf("%v", []byte(verificationCode)))) + + // + // Store pending email change + // + + user.SecurityData.PendingEmail = req.NewEmail + user.SecurityData.PendingEmailCode = verificationCode + user.SecurityData.PendingEmailExpiry = verificationExpiry + user.ModifiedAt = time.Now() + + if err := svc.userUpdateUseCase.Execute(ctx, user); err != nil { + svc.logger.Error("Failed to update user with pending email", + zap.String("user_id", user.ID.String()), + zap.Error(err)) + return nil, httperror.NewInternalServerError("Failed to initiate email change. Please try again.") + } + + // Verify code was saved correctly by reading it back + verifyUser, err := svc.userGetByIDUseCase.Execute(ctx, userID) + if err == nil && verifyUser != nil { + svc.logger.Debug("Verified stored code after save", + zap.String("user_id", userID.String()), + zap.String("stored_code", verifyUser.SecurityData.PendingEmailCode), + zap.String("original_code", verificationCode), + zap.Bool("codes_match", verifyUser.SecurityData.PendingEmailCode == verificationCode)) + } + + // + // Send verification email to NEW address + // + + if err := svc.sendVerificationEmail(ctx, req.NewEmail, user.FirstName, verificationCode); err != nil { + svc.logger.Error("Failed to send verification email to new address", + zap.String("email", validation.MaskEmail(req.NewEmail)), + zap.Error(err)) + + // Rollback: Clear pending email change + user.SecurityData.PendingEmail = "" + user.SecurityData.PendingEmailCode = "" + user.SecurityData.PendingEmailExpiry = time.Time{} + svc.userUpdateUseCase.Execute(ctx, user) + + return nil, httperror.NewInternalServerError("Failed to send verification email. Please try again.") + } + + // + // Send notification to OLD address + // + + if err := svc.sendChangeNotificationEmail(ctx, user.Email, user.FirstName, req.NewEmail); err != nil { + // Log error but don't fail the request - notification is informational + svc.logger.Warn("Failed to send change notification to old email", + zap.String("email", validation.MaskEmail(user.Email)), + zap.Error(err)) + } + + // + // Audit log + // + + svc.auditLogger.LogAuth(ctx, "email_change_requested", auditlog.OutcomeSuccess, + validation.MaskEmail(user.Email), "", map[string]string{ + "user_id": user.ID.String(), + "new_email": validation.MaskEmail(req.NewEmail), + }) + + svc.logger.Info("Email change requested successfully", + zap.String("user_id", user.ID.String()), + zap.String("old_email", validation.MaskEmail(user.Email)), + zap.String("new_email", validation.MaskEmail(req.NewEmail))) + + return &ChangeEmailRequestResponseDTO{ + Message: fmt.Sprintf("Verification code sent to %s. Please check your email and verify within 24 hours.", req.NewEmail), + Success: true, + }, nil +} + +func (svc *changeEmailRequestServiceImpl) generateVerificationCode() string { + // Generate random 8-digit code for increased entropy + // 8 digits = 90,000,000 combinations vs 6 digits = 900,000 + b := make([]byte, 4) + rand.Read(b) + defer memguard.WipeBytes(b) // SECURITY: Wipe random bytes after use + code := int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3]) + code = (code % 90000000) + 10000000 + return fmt.Sprintf("%d", code) +} + +func (svc *changeEmailRequestServiceImpl) sendVerificationEmail(ctx context.Context, email, firstName, code string) error { + subject := "Verify Your New Email Address" + sender := svc.emailer.GetSenderEmail() + + // Escape user input to prevent HTML injection + safeFirstName := html.EscapeString(firstName) + + htmlContent := fmt.Sprintf(` + +
+You requested to change your email address on MapleFile. Please verify your new email address by entering this code:
+This code will expire in 24 hours.
+If you didn't request this change, please ignore this email and contact support immediately.
+Your account email will not be changed until you verify this code.
+ + + `, safeFirstName, code) + + return svc.emailer.Send(ctx, sender, subject, email, htmlContent) +} + +func (svc *changeEmailRequestServiceImpl) sendChangeNotificationEmail(ctx context.Context, oldEmail, firstName, newEmail string) error { + subject := "Email Change Request - Action Required" + sender := svc.emailer.GetSenderEmail() + + // Escape user input to prevent HTML injection + safeFirstName := html.EscapeString(firstName) + safeNewEmail := html.EscapeString(newEmail) // Show full new email so user knows what email is being requested + + htmlContent := fmt.Sprintf(` + + +Important Security Notice: A request was made to change your MapleFile account email address.
+New email address: %s
+If you made this request, you can safely ignore this email. The new email address must be verified before the change takes effect.
+If you did NOT request this change:
+This email change request will expire in 24 hours if not verified.
+ + + `, safeFirstName, safeNewEmail) + + return svc.emailer.Send(ctx, sender, subject, oldEmail, htmlContent) +} diff --git a/cloud/maplefile-backend/internal/service/me/change_email_verify.go b/cloud/maplefile-backend/internal/service/me/change_email_verify.go new file mode 100644 index 0000000..5282b8d --- /dev/null +++ b/cloud/maplefile-backend/internal/service/me/change_email_verify.go @@ -0,0 +1,314 @@ +// codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/me/change_email_verify.go +package me + +import ( + "context" + "errors" + "fmt" + "html" + "strings" + "time" + + "github.com/gocql/gocql" + "go.uber.org/zap" + + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config/constants" + uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/auditlog" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/emailer/mailgun" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/httperror" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" +) + +type ChangeEmailVerifyRequestDTO struct { + VerificationCode string `json:"verification_code"` +} + +type ChangeEmailVerifyResponseDTO struct { + Message string `json:"message"` + Success bool `json:"success"` + NewEmail string `json:"new_email"` +} + +type ChangeEmailVerifyService interface { + Execute(ctx context.Context, req *ChangeEmailVerifyRequestDTO) (*ChangeEmailVerifyResponseDTO, error) +} + +type changeEmailVerifyServiceImpl struct { + config *config.Configuration + logger *zap.Logger + auditLogger auditlog.AuditLogger + userGetByIDUseCase uc_user.UserGetByIDUseCase + userUpdateUseCase uc_user.UserUpdateUseCase + emailer mailgun.Emailer +} + +func NewChangeEmailVerifyService( + config *config.Configuration, + logger *zap.Logger, + auditLogger auditlog.AuditLogger, + userGetByIDUseCase uc_user.UserGetByIDUseCase, + userUpdateUseCase uc_user.UserUpdateUseCase, + emailer mailgun.Emailer, +) ChangeEmailVerifyService { + logger = logger.Named("ChangeEmailVerifyService") + + return &changeEmailVerifyServiceImpl{ + config: config, + logger: logger, + auditLogger: auditLogger, + userGetByIDUseCase: userGetByIDUseCase, + userUpdateUseCase: userUpdateUseCase, + emailer: emailer, + } +} + +func (svc *changeEmailVerifyServiceImpl) Execute(ctx context.Context, req *ChangeEmailVerifyRequestDTO) (*ChangeEmailVerifyResponseDTO, error) { + // + // Get user from context + // + + userID, ok := ctx.Value(constants.SessionUserID).(gocql.UUID) + if !ok { + svc.logger.Error("Failed getting user id from context") + return nil, errors.New("user id not found in context") + } + + // + // Validation + // + + if req == nil { + svc.logger.Warn("Request is nil") + return nil, httperror.NewForBadRequestWithSingleField("non_field_error", "Request is required") + } + + req.VerificationCode = strings.TrimSpace(req.VerificationCode) + + e := make(map[string]string) + if req.VerificationCode == "" { + e["verification_code"] = "Verification code is required" + } else if len(req.VerificationCode) != 8 { + e["verification_code"] = "Verification code must be 8 digits" + } else { + // Validate that code is numeric + for _, c := range req.VerificationCode { + if c < '0' || c > '9' { + e["verification_code"] = "Verification code must contain only numbers" + break + } + } + } + + if len(e) != 0 { + svc.logger.Warn("Validation failed", zap.Any("errors", e)) + return nil, httperror.NewForBadRequest(&e) + } + + // + // Get current user + // + + user, err := svc.userGetByIDUseCase.Execute(ctx, userID) + if err != nil { + svc.logger.Error("Failed getting user by ID", zap.Error(err)) + return nil, err + } + if user == nil { + err := fmt.Errorf("user is nil after lookup for id: %v", userID.String()) + svc.logger.Error("User not found", zap.Error(err)) + return nil, err + } + + // + // Check if there's a pending email change + // + + if user.SecurityData.PendingEmail == "" { + svc.logger.Warn("No pending email change found", + zap.String("user_id", user.ID.String())) + return nil, httperror.NewBadRequestError("No pending email change request found") + } + + // + // Check if verification code matches + // + + svc.logger.Debug("Comparing verification codes", + zap.String("user_id", user.ID.String()), + zap.String("pending_email", validation.MaskEmail(user.SecurityData.PendingEmail)), + zap.String("expected_code", user.SecurityData.PendingEmailCode), + zap.String("provided_code", req.VerificationCode), + zap.Int("expected_length", len(user.SecurityData.PendingEmailCode)), + zap.Int("provided_length", len(req.VerificationCode)), + zap.String("expected_bytes", fmt.Sprintf("%v", []byte(user.SecurityData.PendingEmailCode))), + zap.String("provided_bytes", fmt.Sprintf("%v", []byte(req.VerificationCode)))) + + if user.SecurityData.PendingEmailCode != req.VerificationCode { + svc.logger.Warn("Invalid verification code provided", + zap.String("user_id", user.ID.String()), + zap.String("pending_email", validation.MaskEmail(user.SecurityData.PendingEmail)), + zap.String("expected_code", user.SecurityData.PendingEmailCode), + zap.String("provided_code", req.VerificationCode), + zap.Int("expected_length", len(user.SecurityData.PendingEmailCode)), + zap.Int("provided_length", len(req.VerificationCode))) + + // Audit log failed attempt + svc.auditLogger.LogAuth(ctx, "email_change_verify_failed", auditlog.OutcomeFailure, + validation.MaskEmail(user.Email), "", map[string]string{ + "user_id": user.ID.String(), + "reason": "invalid_code", + }) + + return nil, httperror.NewBadRequestError("Invalid verification code") + } + + // + // Check if verification code has expired + // + + if time.Now().After(user.SecurityData.PendingEmailExpiry) { + svc.logger.Warn("Verification code expired", + zap.String("user_id", user.ID.String()), + zap.Time("expiry", user.SecurityData.PendingEmailExpiry)) + + // Audit log expired attempt + svc.auditLogger.LogAuth(ctx, "email_change_verify_failed", auditlog.OutcomeFailure, + validation.MaskEmail(user.Email), "", map[string]string{ + "user_id": user.ID.String(), + "reason": "code_expired", + }) + + // Clear expired pending email change + user.SecurityData.PendingEmail = "" + user.SecurityData.PendingEmailCode = "" + user.SecurityData.PendingEmailExpiry = time.Time{} + svc.userUpdateUseCase.Execute(ctx, user) + + return nil, httperror.NewBadRequestError("Verification code has expired. Please request a new email change.") + } + + // + // Perform the email change + // + + oldEmail := user.Email + newEmail := user.SecurityData.PendingEmail + + user.Email = newEmail + user.SecurityData.PendingEmail = "" + user.SecurityData.PendingEmailCode = "" + user.SecurityData.PendingEmailExpiry = time.Time{} + user.ModifiedAt = time.Now() + + // IMPORTANT: Email is still verified - don't reset WasEmailVerified flag + // The user has proven ownership of the new email by verifying the code + + if err := svc.userUpdateUseCase.Execute(ctx, user); err != nil { + svc.logger.Error("Failed to update user email", + zap.String("user_id", user.ID.String()), + zap.Error(err)) + return nil, httperror.NewInternalServerError("Failed to complete email change. Please try again.") + } + + // + // Send confirmation emails + // + + // Send confirmation to NEW email + if err := svc.sendConfirmationEmail(ctx, newEmail, user.FirstName, oldEmail); err != nil { + // Log error but don't fail the request - email change succeeded + svc.logger.Warn("Failed to send confirmation email to new address", + zap.String("email", validation.MaskEmail(newEmail)), + zap.Error(err)) + } + + // Send notification to OLD email + if err := svc.sendOldEmailNotification(ctx, oldEmail, user.FirstName, newEmail); err != nil { + // Log error but don't fail the request - email change succeeded + svc.logger.Warn("Failed to send notification to old email", + zap.String("email", validation.MaskEmail(oldEmail)), + zap.Error(err)) + } + + // + // Audit log + // + + svc.auditLogger.LogAuth(ctx, "email_changed", auditlog.OutcomeSuccess, + validation.MaskEmail(newEmail), "", map[string]string{ + "user_id": user.ID.String(), + "old_email": validation.MaskEmail(oldEmail), + "new_email": validation.MaskEmail(newEmail), + }) + + svc.logger.Info("Email changed successfully", + zap.String("user_id", user.ID.String()), + zap.String("old_email", validation.MaskEmail(oldEmail)), + zap.String("new_email", validation.MaskEmail(newEmail))) + + return &ChangeEmailVerifyResponseDTO{ + Message: "Email address changed successfully. You can now log in with your new email address.", + Success: true, + NewEmail: newEmail, + }, nil +} + +func (svc *changeEmailVerifyServiceImpl) sendConfirmationEmail(ctx context.Context, newEmail, firstName, oldEmail string) error { + subject := "Email Address Changed Successfully" + sender := svc.emailer.GetSenderEmail() + + // Escape user input to prevent HTML injection + safeFirstName := html.EscapeString(firstName) + maskedOldEmail := validation.MaskEmail(oldEmail) + + htmlContent := fmt.Sprintf(` + + +Your email address has been changed successfully!
+Previous email: %s
+New email: %s
+You can now log in to MapleFile using your new email address.
+If you did NOT make this change:
+This is a notification that your MapleFile account email address has been changed.
+New email address: %s
+This change was completed at %s.
+If you made this change, you can ignore this email.
+If you did NOT make this change:
+This email was sent to your previous email address for security purposes.
+ + + `, safeFirstName, safeNewEmail, time.Now().Format("January 2, 2006 at 3:04 PM MST")) + + return svc.emailer.Send(ctx, sender, subject, oldEmail, htmlContent) +} diff --git a/cloud/maplefile-backend/internal/service/me/provider.go b/cloud/maplefile-backend/internal/service/me/provider.go index 612ec10..80eff97 100644 --- a/cloud/maplefile-backend/internal/service/me/provider.go +++ b/cloud/maplefile-backend/internal/service/me/provider.go @@ -6,6 +6,8 @@ import ( "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/config" uc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/usecase/user" svc_user "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/internal/service/user" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/auditlog" + "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/emailer/mailgun" ) // Wire providers for me services @@ -50,3 +52,26 @@ func ProvideVerifyProfileService( ) VerifyProfileService { return NewVerifyProfileService(cfg, logger, userGetByIDUseCase, userUpdateUseCase) } + +func ProvideChangeEmailRequestService( + cfg *config.Configuration, + logger *zap.Logger, + auditLogger auditlog.AuditLogger, + userGetByIDUseCase uc_user.UserGetByIDUseCase, + userGetByEmailUseCase uc_user.UserGetByEmailUseCase, + userUpdateUseCase uc_user.UserUpdateUseCase, + emailer mailgun.Emailer, +) ChangeEmailRequestService { + return NewChangeEmailRequestService(cfg, logger, auditLogger, userGetByIDUseCase, userGetByEmailUseCase, userUpdateUseCase, emailer) +} + +func ProvideChangeEmailVerifyService( + cfg *config.Configuration, + logger *zap.Logger, + auditLogger auditlog.AuditLogger, + userGetByIDUseCase uc_user.UserGetByIDUseCase, + userUpdateUseCase uc_user.UserUpdateUseCase, + emailer mailgun.Emailer, +) ChangeEmailVerifyService { + return NewChangeEmailVerifyService(cfg, logger, auditLogger, userGetByIDUseCase, userUpdateUseCase, emailer) +} diff --git a/cloud/maplefile-backend/internal/service/me/update.go b/cloud/maplefile-backend/internal/service/me/update.go index 35f6a3b..91437a1 100644 --- a/cloud/maplefile-backend/internal/service/me/update.go +++ b/cloud/maplefile-backend/internal/service/me/update.go @@ -132,24 +132,16 @@ func (svc *updateMeServiceImpl) Execute(sessCtx context.Context, req *UpdateMeRe } // - // Check if the requested email is already taken by another user. + // Block email changes - must use dedicated email change endpoint + // Note: Both emails are already lowercase (req.Email was lowercased in validation, user.Email is stored lowercase) // - if req.Email != user.Email { - existingUser, err := svc.userGetByEmailUseCase.Execute(sessCtx, req.Email) - if err != nil { - svc.logger.Error("Failed checking existing email", zap.String("email", validation.MaskEmail(req.Email)), zap.Any("error", err)) - return nil, err // Internal Server Error - } - if existingUser != nil { - // Email exists and belongs to another user. - svc.logger.Warn("Attempted to update to an email already in use", - zap.String("user_id", userID.String()), - zap.String("existing_user_id", existingUser.ID.String()), - zap.String("email", validation.MaskEmail(req.Email))) - e["email"] = "This email address is already in use." - return nil, httperror.NewForBadRequest(&e) - } - // If err is mongo.ErrNoDocuments or existingUser is nil, the email is available. + if strings.ToLower(req.Email) != strings.ToLower(user.Email) { + svc.logger.Warn("Attempted to change email via profile update", + zap.String("user_id", userID.String()), + zap.String("old_email", validation.MaskEmail(user.Email)), + zap.String("new_email", validation.MaskEmail(req.Email))) + e["email"] = "Email changes are not allowed through this endpoint. Please use the email change feature in your account settings." + return nil, httperror.NewForBadRequest(&e) } // @@ -157,7 +149,7 @@ func (svc *updateMeServiceImpl) Execute(sessCtx context.Context, req *UpdateMeRe // // Apply changes from request DTO to the user object - user.Email = req.Email + // NOTE: Email is NOT updated here - blocked above user.FirstName = req.FirstName user.LastName = req.LastName user.Name = fmt.Sprintf("%s %s", req.FirstName, req.LastName) diff --git a/go.work.sum b/go.work.sum index 294ac06..80e668f 100644 --- a/go.work.sum +++ b/go.work.sum @@ -303,6 +303,7 @@ github.com/coreos/go-semver v0.2.0 h1:3Jm3tLmsgAYcjC+4Up7hJrFBPr+n7rAqYeSw/SZazu github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/couchbase/ghistogram v0.1.0 h1:b95QcQTCzjTUocDXp/uMgSNQi8oj1tGwnJ4bODWZnps= +github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k= github.com/couchbase/moss v0.2.0 h1:VCYrMzFwEryyhRSeI+/b3tRBSeTpi/8gn5Kf6dxqn+o= github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs= github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= @@ -663,8 +664,10 @@ github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcs github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= @@ -715,6 +718,7 @@ github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaK github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563 h1:dY6ETXrvDG7Sa4vE8ZQG4yqWg6UnOcbqTAahkV813vQ= github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= diff --git a/web/maplefile-frontend/package-lock.json b/web/maplefile-frontend/package-lock.json index 25fbddc..dc112bd 100644 --- a/web/maplefile-frontend/package-lock.json +++ b/web/maplefile-frontend/package-lock.json @@ -88,7 +88,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1772,7 +1771,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1816,7 +1814,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2020,7 +2017,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001718", "electron-to-chromium": "^1.5.160", @@ -2570,7 +2566,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -3998,7 +3993,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4187,7 +4181,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4196,7 +4189,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -4214,7 +4206,6 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -4313,8 +4304,7 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "peer": true + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -4327,8 +4317,7 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "peer": true + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/reselect": { "version": "5.1.1", @@ -4790,7 +4779,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "peer": true, "engines": { "node": ">=12" }, @@ -4907,7 +4895,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -4994,7 +4981,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "peer": true, "engines": { "node": ">=12" }, diff --git a/web/maplefile-frontend/src/pages/User/Me/Detail.jsx b/web/maplefile-frontend/src/pages/User/Me/Detail.jsx index 38fd78d..34fc214 100644 --- a/web/maplefile-frontend/src/pages/User/Me/Detail.jsx +++ b/web/maplefile-frontend/src/pages/User/Me/Detail.jsx @@ -64,7 +64,6 @@ const MeDetail = () => { const [editLoading, setEditLoading] = useState(false); const [editError, setEditError] = useState(""); const [formData, setFormData] = useState({ - email: "", first_name: "", last_name: "", phone: "", @@ -73,6 +72,14 @@ const MeDetail = () => { share_notifications_enabled: true, }); + // Email change states + const [showEmailChangeModal, setShowEmailChangeModal] = useState(false); + const [emailChangeStep, setEmailChangeStep] = useState(1); // 1 = request, 2 = verify + const [newEmail, setNewEmail] = useState(""); + const [verificationCode, setVerificationCode] = useState(""); + const [emailChangeLoading, setEmailChangeLoading] = useState(false); + const [emailChangeError, setEmailChangeError] = useState(""); + const [emailChangeSuccess, setEmailChangeSuccess] = useState(""); // Note: Delete account functionality moved to dedicated /me/delete-account page @@ -135,7 +142,6 @@ const MeDetail = () => { if (isMountedRef.current) { setUserProfile(profile); setFormData({ - email: profile.email || "", first_name: profile.first_name || "", last_name: profile.last_name || "", phone: profile.phone || "", @@ -232,7 +238,6 @@ const MeDetail = () => { const handleCancelEdit = () => { if (userProfile) { setFormData({ - email: userProfile.email || "", first_name: userProfile.first_name || "", last_name: userProfile.last_name || "", phone: userProfile.phone || "", @@ -248,6 +253,64 @@ const MeDetail = () => { // Delete account handler removed - now using dedicated /me/delete-account page + // Email change handlers + const handleEmailChangeRequest = async (e) => { + e.preventDefault(); + setEmailChangeLoading(true); + setEmailChangeError(""); + + try { + await meManager.apiService.requestEmailChange(newEmail); + setEmailChangeStep(2); + setEmailChangeSuccess(`Verification code sent to ${newEmail}. Please check your email.`); + } catch (err) { + if (import.meta.env.DEV) { + console.error("Failed to request email change:", err); + } + setEmailChangeError(err.message || "Failed to request email change"); + } finally { + setEmailChangeLoading(false); + } + }; + + const handleEmailChangeVerify = async (e) => { + e.preventDefault(); + setEmailChangeLoading(true); + setEmailChangeError(""); + + try { + const result = await meManager.apiService.verifyEmailChange(verificationCode); + setEmailChangeSuccess("Email changed successfully! Refreshing your profile..."); + + // Refresh profile to get updated email + setTimeout(async () => { + await loadUserProfile(true); + setShowEmailChangeModal(false); + setEmailChangeStep(1); + setNewEmail(""); + setVerificationCode(""); + setEmailChangeSuccess(""); + }, 2000); + } catch (err) { + if (import.meta.env.DEV) { + console.error("Failed to verify email change:", err); + } + setEmailChangeError(err.message || "Failed to verify email change"); + } finally { + setEmailChangeLoading(false); + } + }; + + const handleCancelEmailChange = () => { + setShowEmailChangeModal(false); + setEmailChangeStep(1); + setNewEmail(""); + setVerificationCode(""); + setEmailChangeError(""); + setEmailChangeSuccess(""); + setEmailChangeLoading(false); + }; + const formatDate = (dateString) => { if (!dateString) return "N/A"; try { @@ -485,19 +548,6 @@ const MeDetail = () => { disabled={editLoading} required /> - { - setFormData((prev) => ({ ...prev, email: value })); - if (editError) setEditError(""); - }} - disabled={editLoading} - required - /> { + {/* Change Email Section */} ++ Update your email address. You'll need to verify your new email. +
+ + {!showEmailChangeModal ? ( ++ Current Email +
++ {userProfile?.email} +
+