From 10a66cd97947d7541d2bf5bf2c5efc13c0814a90 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Fri, 30 Oct 2026 20:19:07 +0100 Subject: [PATCH] feat: change email, export my data, delete account (with data anonymization) --- api/dependecyInjection.go | 23 +- api/handlers/auth/emailChangeHandler.go | 151 ++++ api/handlers/auth/loginHandler.go | 11 +- api/handlers/auth/meHandler.go | 14 +- api/handlers/user/deleteAccountHandler.go | 81 ++ api/handlers/user/exportHandler.go | 44 + api/middleware/successRateLimit.go | 90 ++ api/pkg/apperr/messages.go | 9 + api/pkg/authtoken/cookie.go | 19 + api/router.go | 52 +- docs/api.md | 95 ++- docs/privacy.md | 16 +- features/account_change_email.feature | 292 +++++++ features/account_change_email.qa.md | 110 +++ features/account_delete.feature | 235 ++++++ features/account_delete.qa.md | 101 +++ features/account_export_data.feature | 135 +++ features/account_export_data.qa.md | 72 ++ .../article/articleRepositoryInterface.go | 1 + internal/domain/audit/auditDomain.go | 7 + internal/domain/audit/auditOwnership.go | 19 + .../domain/audit/auditRepositoryInterface.go | 1 + internal/domain/author/authorDeleted.go | 29 + internal/domain/author/authorDomain.go | 6 +- .../domain/entry/entryRepositoryInterface.go | 1 + .../domain/image/imageRepositoryInterface.go | 1 + .../domain/mail/mailDispatcherInterface.go | 5 + internal/domain/user/userDomain.go | 10 + internal/domain/user/userErasure.go | 42 + .../domain/user/userRepositoryInterface.go | 4 + .../infrastructure/mailer/asyncDispatcher.go | 25 + .../infrastructure/mailer/mailRepository.go | 6 + .../mailer/templates/account-deleted.html | 62 ++ .../mailer/templates/email-change-notice.html | 69 ++ .../mailer/templates/verify-new-email.html | 72 ++ .../persistence/mappers/userMapper.go | 6 + .../persistence/models/auditEventModel.go | 4 +- .../persistence/models/userModel.go | 3 + .../persistence/repositories/accountEraser.go | 145 ++++ .../repositories/articleRepository.go | 15 + .../repositories/auditEventRepository.go | 36 + .../repositories/entryRepository.go | 8 + .../repositories/imageRepository.go | 8 + .../repositories/userRepository.go | 74 ++ .../useCases/CancelEmailChange.go | 34 + .../useCases/ConfirmEmailChange.go | 52 ++ .../useCases/DeleteOwnAccount.go | 199 +++++ .../infrastructure/useCases/ExportUserData.go | 356 ++++++++ .../useCases/RequestEmailChange.go | 60 ++ .../useCases/ResendEmailChange.go | 41 + .../infrastructure/useCases/auditFailures.go | 8 + .../useCases/emailChangeMail.go | 81 ++ test/account_change_email_dispatcher_test.go | 84 ++ test/account_change_email_http_test.go | 698 ++++++++++++++++ test/account_change_email_persistence_test.go | 228 ++++++ test/account_delete_contention_test.go | 81 ++ test/account_delete_http_test.go | 775 ++++++++++++++++++ test/account_delete_parity_test.go | 64 ++ test/account_delete_persistence_test.go | 267 ++++++ test/account_delete_slug_test.go | 71 ++ test/account_export_data_emptiness_test.go | 100 +++ test/account_export_data_http_test.go | 528 ++++++++++++ test/account_export_data_persistence_test.go | 129 +++ test/account_export_data_scope_test.go | 231 ++++++ test/activity_domain_test.go | 4 +- test/activity_persistence_test.go | 4 +- test/activity_retention_size_test.go | 2 + test/activity_support_test.go | 4 + test/api_catalogue_drift_test.go | 6 + test/article_test.go | 15 + test/custom_content_types_support_test.go | 15 + test/get_current_user_http_test.go | 21 + test/image_test.go | 19 + test/sqlite_schema_unchanged_test.go | 8 + test/transactional_email_templates_test.go | 56 ++ test/user_display_name_test.go | 2 +- test/user_test.go | 80 ++ test/users_api_http_test.go | 2 +- web/src/components/BlockedDialog.vue | 73 ++ web/src/components/DangerZoneCard.vue | 61 ++ web/src/components/DataExportCard.vue | 133 +++ web/src/components/DeleteAccountDialog.vue | 226 +++++ web/src/components/EmailChangeForm.vue | 129 +++ web/src/components/PendingEmailNotice.vue | 82 ++ web/src/components/ProfileCard.vue | 4 +- web/src/composables/useAccountDeletion.ts | 122 +++ web/src/composables/useActivity.ts | 11 - web/src/composables/useActivityExport.ts | 3 +- web/src/composables/useApi.ts | 27 +- web/src/composables/useDataExport.ts | 98 +++ web/src/composables/useEmailChange.ts | 290 +++++++ web/src/composables/useEntryList.ts | 10 +- web/src/composables/useEntryNames.ts | 6 +- web/src/composables/useProfile.ts | 52 +- web/src/i18n/accountDeletion.spec.ts | 87 ++ web/src/i18n/messages.ts | 116 +++ .../lib/__tests__/activitySentence.spec.ts | 59 ++ .../lib/__tests__/contentDisposition.spec.ts | 35 + web/src/lib/__tests__/deletedUser.spec.ts | 22 + web/src/lib/__tests__/downloadBlob.spec.ts | 45 + web/src/lib/accountDeletion.ts | 6 + web/src/lib/activitySentence.ts | 24 + web/src/lib/apiError.ts | 6 + web/src/lib/contentDisposition.ts | 13 + web/src/lib/deletedUser.ts | 9 + web/src/lib/downloadBlob.ts | 12 + web/src/lib/emailChange.ts | 7 + web/src/router/index.ts | 5 + web/src/stores/session.ts | 15 + web/src/views/EmailConfirmView.vue | 454 ++++++++++ web/src/views/LoginView.vue | 31 +- web/src/views/ProfileView.vue | 51 +- web/src/views/__tests__/ContentList.spec.ts | 13 +- .../views/__tests__/EmailConfirmView.spec.ts | 268 ++++++ .../views/__tests__/EntryEditorLoad.spec.ts | 4 +- web/src/views/__tests__/LoginView.spec.ts | 24 + .../views/__tests__/ProfileAuthorCard.spec.ts | 3 +- .../views/__tests__/ProfileDataExport.spec.ts | 242 ++++++ .../__tests__/ProfileDeleteAccount.spec.ts | 514 ++++++++++++ .../__tests__/ProfileEmailChange.spec.ts | 410 +++++++++ web/src/views/__tests__/ProfileView.spec.ts | 3 +- web/visual/check.ts | 98 +++ web/visual/states.ts | 428 ++++++++++ 123 files changed, 10789 insertions(+), 76 deletions(-) create mode 100644 api/handlers/auth/emailChangeHandler.go create mode 100644 api/handlers/user/deleteAccountHandler.go create mode 100644 api/handlers/user/exportHandler.go create mode 100644 api/middleware/successRateLimit.go create mode 100644 api/pkg/authtoken/cookie.go create mode 100644 features/account_change_email.feature create mode 100644 features/account_change_email.qa.md create mode 100644 features/account_delete.feature create mode 100644 features/account_delete.qa.md create mode 100644 features/account_export_data.feature create mode 100644 features/account_export_data.qa.md create mode 100644 internal/domain/audit/auditOwnership.go create mode 100644 internal/domain/author/authorDeleted.go create mode 100644 internal/domain/mail/mailDispatcherInterface.go create mode 100644 internal/domain/user/userErasure.go create mode 100644 internal/infrastructure/mailer/asyncDispatcher.go create mode 100644 internal/infrastructure/mailer/templates/account-deleted.html create mode 100644 internal/infrastructure/mailer/templates/email-change-notice.html create mode 100644 internal/infrastructure/mailer/templates/verify-new-email.html create mode 100644 internal/infrastructure/persistence/repositories/accountEraser.go create mode 100644 internal/infrastructure/useCases/CancelEmailChange.go create mode 100644 internal/infrastructure/useCases/ConfirmEmailChange.go create mode 100644 internal/infrastructure/useCases/DeleteOwnAccount.go create mode 100644 internal/infrastructure/useCases/ExportUserData.go create mode 100644 internal/infrastructure/useCases/RequestEmailChange.go create mode 100644 internal/infrastructure/useCases/ResendEmailChange.go create mode 100644 internal/infrastructure/useCases/emailChangeMail.go create mode 100644 test/account_change_email_dispatcher_test.go create mode 100644 test/account_change_email_http_test.go create mode 100644 test/account_change_email_persistence_test.go create mode 100644 test/account_delete_contention_test.go create mode 100644 test/account_delete_http_test.go create mode 100644 test/account_delete_parity_test.go create mode 100644 test/account_delete_persistence_test.go create mode 100644 test/account_delete_slug_test.go create mode 100644 test/account_export_data_emptiness_test.go create mode 100644 test/account_export_data_http_test.go create mode 100644 test/account_export_data_persistence_test.go create mode 100644 test/account_export_data_scope_test.go create mode 100644 web/src/components/BlockedDialog.vue create mode 100644 web/src/components/DangerZoneCard.vue create mode 100644 web/src/components/DataExportCard.vue create mode 100644 web/src/components/DeleteAccountDialog.vue create mode 100644 web/src/components/EmailChangeForm.vue create mode 100644 web/src/components/PendingEmailNotice.vue create mode 100644 web/src/composables/useAccountDeletion.ts create mode 100644 web/src/composables/useDataExport.ts create mode 100644 web/src/composables/useEmailChange.ts create mode 100644 web/src/i18n/accountDeletion.spec.ts create mode 100644 web/src/lib/__tests__/contentDisposition.spec.ts create mode 100644 web/src/lib/__tests__/deletedUser.spec.ts create mode 100644 web/src/lib/__tests__/downloadBlob.spec.ts create mode 100644 web/src/lib/accountDeletion.ts create mode 100644 web/src/lib/contentDisposition.ts create mode 100644 web/src/lib/deletedUser.ts create mode 100644 web/src/lib/downloadBlob.ts create mode 100644 web/src/lib/emailChange.ts create mode 100644 web/src/views/EmailConfirmView.vue create mode 100644 web/src/views/__tests__/EmailConfirmView.spec.ts create mode 100644 web/src/views/__tests__/ProfileDataExport.spec.ts create mode 100644 web/src/views/__tests__/ProfileDeleteAccount.spec.ts create mode 100644 web/src/views/__tests__/ProfileEmailChange.spec.ts diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index f291fc7..32a7b41 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -98,6 +98,10 @@ type UseCases struct { RequestPasswordResetUseCase *useCases.RequestPasswordResetUseCase ResetPasswordUseCase *useCases.ResetPasswordUseCase UpdateUserProfileUseCase *useCases.UpdateUserProfileUseCase + RequestEmailChangeUseCase *useCases.RequestEmailChangeUseCase + ResendEmailChangeUseCase *useCases.ResendEmailChangeUseCase + CancelEmailChangeUseCase *useCases.CancelEmailChangeUseCase + ConfirmEmailChangeUseCase *useCases.ConfirmEmailChangeUseCase ListUsersUseCase *useCases.ListUsersUseCase CreateInviteUseCase *useCases.CreateInviteUseCase CancelInviteUseCase *useCases.CancelInviteUseCase @@ -105,6 +109,8 @@ type UseCases struct { ListUsersAndInvitesUseCase *useCases.ListUsersAndInvitesUseCase ChangeUserRoleUseCase *useCases.ChangeUserRoleUseCase RemoveUserUseCase *useCases.RemoveUserUseCase + ExportUserDataUseCase *useCases.ExportUserDataUseCase + DeleteOwnAccountUseCase *useCases.DeleteOwnAccountUseCase TransferOwnershipUseCase *useCases.TransferOwnershipUseCase GetInviteUseCase *useCases.GetInviteUseCase AcceptInviteUseCase *useCases.AcceptInviteUseCase @@ -174,6 +180,7 @@ type Repositories struct { EntryRepo domainEntry.Repository AuthorRepo domainAuthor.Repository UserRepo domainUser.Repository + UserEraser domainUser.Eraser RoleRepo domainRole.Repository ImageRepo domainImage.Repository Transactor domainTransaction.Transactor @@ -208,6 +215,8 @@ const ( updateHelperFileName = "update-helper.json" updatesDirName = "updates" backupsDirName = "backups" + + maxConcurrentDataExports = 2 ) func imageStorageConfig(settings config.Settings) storage.Config { @@ -319,6 +328,7 @@ func getRepositories(connection *database.Connection, settings config.Settings) EntryRepo: persistence.NewEntryRepository(db), AuthorRepo: persistence.NewAuthorRepository(db), UserRepo: persistence.NewUserRepository(db), + UserEraser: persistence.NewAccountEraser(db), RoleRepo: persistence.NewRoleRepository(db), ImageRepo: persistence.NewImageRepository(db), Transactor: persistence.NewTransactor(db), @@ -350,9 +360,12 @@ func getRepositories(connection *database.Connection, settings config.Settings) func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases { hasher := hashing.NewBcryptHasher() + mailDispatcher := mailer.NewAsyncDispatcher() + sessionRevocation := useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now) renderer := markdown.NewConverter() keyGenerator := hashing.NewSHA256KeyGenerator() recorder := auditing.NewRecorder(repos.AuditRepo, time.Now, log.Default()) + mediaUsage := useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo) mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage, repos.VariantFileRepo, recorder, repos.ContentTypeRepo, repos.EntryRepo, repos.Transactor, log.Default()) runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default(), recorder, repos.SiteRepo, repos.AuthorRepo) @@ -366,7 +379,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings PurgeScheduler: scheduler.NewSystemRunner(purgeScheduler, log.Default()), ActivityCleanupScheduler: scheduler.NewSystemCleanupRunner(activityCleanup, log.Default()), RetentionScheduler: scheduler.NewSystemRetentionRunner(useCases.NewEnforceRetentionUseCase(repos.UserRepo, repos.InviteRepo, repos.SessionRepo), log.Default()), - Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(repos.SessionRepo, time.Now))), + Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)), CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo), GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer), ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo), @@ -416,6 +429,10 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(repos.UserRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), ResetPasswordUseCase: useCases.NewResetPasswordUseCase(repos.UserRepo, hasher, repos.APIKeyRepo, time.Now, recorder), UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(repos.UserRepo, recorder), + RequestEmailChangeUseCase: useCases.NewRequestEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder), + ResendEmailChangeUseCase: useCases.NewResendEmailChangeUseCase(repos.UserRepo, hasher, repos.MailRepo, mailDispatcher, settings.PublicURL, time.Now, recorder), + CancelEmailChangeUseCase: useCases.NewCancelEmailChangeUseCase(repos.UserRepo, recorder), + ConfirmEmailChangeUseCase: useCases.NewConfirmEmailChangeUseCase(repos.UserRepo, hasher, time.Now, recorder), ListUsersUseCase: useCases.NewListUsersUseCase(repos.UserRepo), CreateInviteUseCase: useCases.NewCreateInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, repos.ContentTypeRepo, hasher, repos.MailRepo, settings.PublicURL, recorder), CancelInviteUseCase: useCases.NewCancelInviteUseCase(repos.InviteRepo, recorder), @@ -423,6 +440,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo), ChangeUserRoleUseCase: useCases.NewChangeUserRoleUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder), RemoveUserUseCase: useCases.NewRemoveUserUseCase(repos.UserRepo, repos.RoleRepo, repos.ContentTypeRepo, recorder), + ExportUserDataUseCase: useCases.NewExportUserDataUseCase(repos.UserRepo, repos.RoleRepo, repos.AuthorRepo, repos.APIKeyRepo, repos.ArticleRepo, repos.EntryRepo, repos.ContentTypeRepo, repos.ImageRepo, repos.AuditRepo, time.Now, recorder, maxConcurrentDataExports), + DeleteOwnAccountUseCase: useCases.NewDeleteOwnAccountUseCase(repos.UserRepo, repos.RoleRepo, hasher, repos.UserEraser, sessionRevocation, repos.MailRepo, mailDispatcher, settings.PublicURL, recorder, repos.AuthorRepo, repos.ImageRepo, mediaUsage, imageStorage, repos.VariantFileRepo), TransferOwnershipUseCase: useCases.NewTransferOwnershipUseCase(repos.UserRepo, repos.RoleRepo, recorder), GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, repos.RoleRepo, hasher, recorder), @@ -433,7 +452,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo, recorder), DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage, repos.VariantFileRepo, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ArticleRepo), MergeMediaUseCase: mergeMedia, - GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo), + GetMediaUsageUseCase: mediaUsage, GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(repos.PurgeSettingsRepo, time.Now), PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, repos.SiteRepo, repos.AuthorRepo, repos.ContentTypeRepo, repos.EntryRepo), diff --git a/api/handlers/auth/emailChangeHandler.go b/api/handlers/auth/emailChangeHandler.go new file mode 100644 index 0000000..c37e011 --- /dev/null +++ b/api/handlers/auth/emailChangeHandler.go @@ -0,0 +1,151 @@ +package auth + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/api/pkg/claims" + validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "errors" + "net/http" +) + +type EmailChangeHandler struct { + RequestUseCase *useCases.RequestEmailChangeUseCase + ResendUseCase *useCases.ResendEmailChangeUseCase + CancelUseCase *useCases.CancelEmailChangeUseCase + ConfirmUseCase *useCases.ConfirmEmailChangeUseCase +} + +type RequestEmailChangeCredentials struct { + NewEmail string `json:"new_email" validate:"required,max=255,inviteemail"` + CurrentPassword string `json:"current_password" validate:"required"` +} + +type ConfirmEmailChangeCredentials struct { + Code string `json:"code" validate:"required"` +} + +func NewEmailChangeHandler(request *useCases.RequestEmailChangeUseCase, resend *useCases.ResendEmailChangeUseCase, cancel *useCases.CancelEmailChangeUseCase, confirm *useCases.ConfirmEmailChangeUseCase) *EmailChangeHandler { + return &EmailChangeHandler{ + RequestUseCase: request, + ResendUseCase: resend, + CancelUseCase: cancel, + ConfirmUseCase: confirm, + } +} + +func (h *EmailChangeHandler) Request(w http.ResponseWriter, r *http.Request) { + var credentials RequestEmailChangeCredentials + if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } + if err := validator.Validate.Struct(credentials); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + userId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + + pending, err := h.RequestUseCase.RequestEmailChange(r.Context(), useCases.RequestEmailChangeCommand{ + UserID: userId, + NewEmail: credentials.NewEmail, + CurrentPassword: credentials.CurrentPassword, + }) + if err != nil { + writeEmailChangeError(w, err) + return + } + writePendingEmail(w, pending) +} + +func (h *EmailChangeHandler) Resend(w http.ResponseWriter, r *http.Request) { + userId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + + pending, err := h.ResendUseCase.ResendEmailChange(r.Context(), userId) + if err != nil { + writeEmailChangeError(w, err) + return + } + writePendingEmail(w, pending) +} + +func (h *EmailChangeHandler) Cancel(w http.ResponseWriter, r *http.Request) { + userId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + + if err := h.CancelUseCase.CancelEmailChange(r.Context(), userId); err != nil { + writeEmailChangeError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *EmailChangeHandler) Confirm(w http.ResponseWriter, r *http.Request) { + var credentials ConfirmEmailChangeCredentials + if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } + if err := validator.Validate.Struct(credentials); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + userId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + + email, err := h.ConfirmUseCase.ConfirmEmailChange(r.Context(), userId, credentials.Code) + if err != nil { + writeEmailChangeError(w, err) + return + } + body, err := json.Marshal(map[string]string{"email": email}) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + _, _ = w.Write(body) +} + +func writePendingEmail(w http.ResponseWriter, pending string) { + body, err := json.Marshal(map[string]string{"pending_email": pending}) + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + _, _ = w.Write(body) +} + +func writeEmailChangeError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, user.ErrUserNotFound): + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + case errors.Is(err, user.ErrWrongPassword): + http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden) + case errors.Is(err, user.ErrSameEmail): + http.Error(w, apperr.SameEmailErrorCode, http.StatusBadRequest) + case errors.Is(err, user.ErrNoPendingEmailChange): + http.Error(w, apperr.NoPendingEmailChangeErrorCode, http.StatusNotFound) + case errors.Is(err, user.ErrInvalidEmailChangeCode): + http.Error(w, apperr.InvalidEmailChangeCodeErrorCode, http.StatusForbidden) + case errors.Is(err, user.ErrEmailUnavailable): + http.Error(w, apperr.EmailUnavailableErrorCode, http.StatusConflict) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/api/handlers/auth/loginHandler.go b/api/handlers/auth/loginHandler.go index 5703663..efb0915 100644 --- a/api/handlers/auth/loginHandler.go +++ b/api/handlers/auth/loginHandler.go @@ -40,16 +40,7 @@ func NewLoginHandler(token *authtoken.Auth, create *useCases.CreateUserUseCase, } func removeJwtCookie(w http.ResponseWriter, isProduction bool) { - http.SetCookie(w, &http.Cookie{ - Name: authtoken.CookieName, - Value: "", - Expires: time.Now(), - MaxAge: -1, - Secure: isProduction, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Path: "/", - }) + http.SetCookie(w, authtoken.ExpiredCookie(isProduction)) } func setJwtCookie(w http.ResponseWriter, token *authtoken.Auth, isProduction bool, u user.User) error { diff --git a/api/handlers/auth/meHandler.go b/api/handlers/auth/meHandler.go index 81f82b9..4d1658c 100644 --- a/api/handlers/auth/meHandler.go +++ b/api/handlers/auth/meHandler.go @@ -41,8 +41,9 @@ type meAccess struct { type meResponse struct { publicuser.PublicUser - Author *meAuthor `json:"author"` - Access meAccess `json:"access"` + PendingEmail *string `json:"pending_email"` + Author *meAuthor `json:"author"` + Access meAccess `json:"access"` } func toMeAccess(access useCases.Access) meAccess { @@ -60,6 +61,13 @@ func toMeAuthor(found *author.Author) *meAuthor { return &meAuthor{Byline: found.Byline(), ArticleCount: found.ArticleCount} } +func pendingEmailOf(account user.User) *string { + if account.PendingEmail == "" { + return nil + } + return &account.PendingEmail +} + func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) { userId, ok := claims.UserId(r) if !ok { @@ -89,7 +97,7 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) { return } - body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)}) + body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), PendingEmail: pendingEmailOf(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)}) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return diff --git a/api/handlers/user/deleteAccountHandler.go b/api/handlers/user/deleteAccountHandler.go new file mode 100644 index 0000000..98e9481 --- /dev/null +++ b/api/handlers/user/deleteAccountHandler.go @@ -0,0 +1,81 @@ +package user + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/api/pkg/authtoken" + "RenewCMS/api/pkg/claims" + validator "RenewCMS/api/pkg/validator" + domain "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/useCases" + "encoding/json" + "errors" + "net/http" + "time" +) + +type DeleteAccountHandler struct { + DeleteUseCase *useCases.DeleteOwnAccountUseCase + IsProduction bool +} + +type DeleteAccountRequest struct { + CurrentPassword string `json:"current_password" validate:"required"` + Confirmation string `json:"confirmation" validate:"required,eq=DELETE"` +} + +func NewDeleteAccountHandler(deleteAccount *useCases.DeleteOwnAccountUseCase, isProduction bool) *DeleteAccountHandler { + return &DeleteAccountHandler{DeleteUseCase: deleteAccount, IsProduction: isProduction} +} + +func (h *DeleteAccountHandler) Delete(w http.ResponseWriter, r *http.Request) { + var request DeleteAccountRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return + } + if err := validator.Validate.Struct(request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + userId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + + command := useCases.DeleteOwnAccountCommand{UserID: userId, CurrentPassword: request.CurrentPassword} + command.SessionID, command.SessionExpiresAt = sessionOf(r) + if err := h.DeleteUseCase.Delete(r.Context(), command); err != nil { + writeDeleteAccountError(w, err) + return + } + http.SetCookie(w, authtoken.ExpiredCookie(h.IsProduction)) + w.WriteHeader(http.StatusNoContent) +} + +func sessionOf(r *http.Request) (string, time.Time) { + tokenClaims, err := authtoken.FromContext(r.Context()) + if err != nil { + return "", time.Time{} + } + id, _ := tokenClaims["jti"].(string) + expiry, _ := tokenClaims["exp"].(float64) + return id, time.Unix(int64(expiry), 0) +} + +func writeDeleteAccountError(w http.ResponseWriter, err error) { + var lastAdmin domain.LastAdminError + switch { + case errors.Is(err, domain.ErrUserNotFound): + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + case errors.Is(err, domain.ErrWrongPassword): + http.Error(w, apperr.WrongPasswordErrorCode, http.StatusForbidden) + case errors.As(err, &lastAdmin): + w.Header().Set(apperr.LastRoleHeader, lastAdmin.Role) + http.Error(w, apperr.LastAdminErrorCode, http.StatusConflict) + case errors.Is(err, domain.ErrAccountNotDeletable): + http.Error(w, apperr.AccountNotDeletableErrorCode, http.StatusConflict) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/api/handlers/user/exportHandler.go b/api/handlers/user/exportHandler.go new file mode 100644 index 0000000..0c489cb --- /dev/null +++ b/api/handlers/user/exportHandler.go @@ -0,0 +1,44 @@ +package user + +import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/api/pkg/claims" + "RenewCMS/internal/infrastructure/useCases" + "context" + "errors" + "net/http" + "strconv" +) + +type ExportHandler struct { + ExportUseCase *useCases.ExportUserDataUseCase +} + +func NewExportHandler(export *useCases.ExportUserDataUseCase) *ExportHandler { + return &ExportHandler{ExportUseCase: export} +} + +func (h *ExportHandler) Export(w http.ResponseWriter, r *http.Request) { + userId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return + } + + archive, err := h.ExportUseCase.Export(r.Context(), userId) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + http.Error(w, "Service unavailable", http.StatusServiceUnavailable) + return + } + if err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Disposition", "attachment; filename=\""+archive.Filename+"\"") + w.Header().Set("Content-Length", strconv.Itoa(len(archive.Content))) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(archive.Content) +} diff --git a/api/middleware/successRateLimit.go b/api/middleware/successRateLimit.go new file mode 100644 index 0000000..b76169a --- /dev/null +++ b/api/middleware/successRateLimit.go @@ -0,0 +1,90 @@ +package middleware + +import ( + "RenewCMS/api/pkg/apperr" + "net/http" + "strconv" + "sync" + "time" +) + +type successSlots struct { + mu sync.Mutex + window time.Duration + now func() time.Time + taken map[string]time.Time +} + +func NewUserSuccessLimiter(window time.Duration) func(http.Handler) http.Handler { + return NewUserSuccessLimiterAt(window, time.Now) +} + +func NewUserSuccessLimiterAt(window time.Duration, now func() time.Time) func(http.Handler) http.Handler { + slots := &successSlots{window: window, now: now, taken: make(map[string]time.Time)} + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := userRateLimitKey(r) + claimed, retryAfter, granted := slots.claim(key) + if !granted { + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests) + return + } + + recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + succeeded := false + defer func() { + if !succeeded { + slots.release(key, claimed) + } + }() + next.ServeHTTP(recorder, r) + succeeded = recorder.status < http.StatusBadRequest + }) + } +} + +func (s *successSlots) claim(key string) (time.Time, int, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + now := s.now() + s.sweepExpired(now) + if claimedAt, held := s.taken[key]; held { + return time.Time{}, retryAfterSeconds(s.window - now.Sub(claimedAt)), false + } + s.taken[key] = now + return now, 0, true +} + +func (s *successSlots) release(key string, claimedAt time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + + if current, held := s.taken[key]; held && current.Equal(claimedAt) { + delete(s.taken, key) + } +} + +func (s *successSlots) sweepExpired(now time.Time) { + for key, claimedAt := range s.taken { + if now.Sub(claimedAt) >= s.window { + delete(s.taken, key) + } + } +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(status int) { + s.status = status + s.ResponseWriter.WriteHeader(status) +} + +func (s *statusRecorder) Unwrap() http.ResponseWriter { + return s.ResponseWriter +} diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index cd66146..4a13f93 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -127,3 +127,12 @@ const InvalidRoleErrorMessage = "invalid_role" const UserRoleChangedErrorMessage = "user_role_changed" const InvalidTransferErrorMessage = "invalid_transfer" const ConfirmationMismatchErrorMessage = "confirmation_mismatch" +const WrongPasswordErrorCode = "wrong_password" +const SameEmailErrorCode = "same_email" +const NoPendingEmailChangeErrorCode = "no_pending_email_change" +const InvalidEmailChangeCodeErrorCode = "invalid_email_change_code" +const EmailUnavailableErrorCode = "email_unavailable" + +const LastAdminErrorCode = "last_admin" +const AccountNotDeletableErrorCode = "account_not_deletable" +const LastRoleHeader = "X-Last-Role" diff --git a/api/pkg/authtoken/cookie.go b/api/pkg/authtoken/cookie.go new file mode 100644 index 0000000..f857f57 --- /dev/null +++ b/api/pkg/authtoken/cookie.go @@ -0,0 +1,19 @@ +package authtoken + +import ( + "net/http" + "time" +) + +func ExpiredCookie(secure bool) *http.Cookie { + return &http.Cookie{ + Name: CookieName, + Value: "", + Expires: time.Now(), + MaxAge: -1, + Secure: secure, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Path: "/", + } +} diff --git a/api/router.go b/api/router.go index 8b30297..56d6e19 100644 --- a/api/router.go +++ b/api/router.go @@ -52,6 +52,14 @@ func GetHelloWorld(w http.ResponseWriter, _ *http.Request) { const ( apiKeyMaxRequests = 120 apiKeyWindow = time.Minute + + emailChangeMaxRequests = 5 + emailChangeWindow = 15 * time.Minute + + dataExportWindow = 15 * time.Minute + + accountDeleteMaxRequests = 5 + accountDeleteWindow = 15 * time.Minute ) func (c *UseCases) apiKeyAuth() func(http.Handler) http.Handler { @@ -119,6 +127,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction) meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase) passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase) + emailChangeHandler := auth.NewEmailChangeHandler(container.RequestEmailChangeUseCase, container.ResendEmailChangeUseCase, container.CancelEmailChangeUseCase, container.ConfirmEmailChangeUseCase) acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction) r.Post("/register", loginHandler.Register) @@ -149,6 +158,22 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth r.Post("/verify", verificationHandler.Verify) r.Delete("/register", verificationHandler.CancelRegistration) }) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireSession) + r.Use(authtoken.Verifier(container.Token)) + r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) + r.Use(middleware.RequireSignedIn) + r.Use(authtoken.Authenticator) + r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) + r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) + r.Delete("/email/change", emailChangeHandler.Cancel) + r.Post("/email/confirm", emailChangeHandler.Confirm) + r.Group(func(r chi.Router) { + r.Use(middleware.NewUserRateLimiter(emailChangeMaxRequests, emailChangeWindow)) + r.Post("/email/change", emailChangeHandler.Request) + r.Post("/email/resend", emailChangeHandler.Resend) + }) + }) r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(container.Token)) r.Use(container.apiKeyAuth()) @@ -159,7 +184,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth return r } -func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler { +func NewUserRouter(container *UseCases, isProduction bool, rateLimit config.AuthRateLimitSettings, trustedProxies []string) http.Handler { r := chi.NewRouter() userHandler := user.NewHandler(container.UpdateUserProfileUseCase) @@ -167,6 +192,8 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, inviteHandler := user.NewInviteHandler(container.CreateInviteUseCase, container.CancelInviteUseCase, container.RenewInviteUseCase, container.ListUsersAndInvitesUseCase) roleHandler := user.NewRoleHandler(container.ChangeUserRoleUseCase) removeHandler := user.NewRemoveHandler(container.RemoveUserUseCase) + exportHandler := user.NewExportHandler(container.ExportUserDataUseCase) + deleteAccountHandler := user.NewDeleteAccountHandler(container.DeleteOwnAccountUseCase, isProduction) r.Group(func(r chi.Router) { r.Use(authtoken.Verifier(container.Token)) @@ -187,6 +214,27 @@ func NewUserRouter(container *UseCases, rateLimit config.AuthRateLimitSettings, r.Post("/invite/{id}/renew", inviteHandler.RenewInvite) }) }) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireSession) + r.Use(authtoken.Verifier(container.Token)) + r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) + r.Use(middleware.RequireSignedIn) + r.Use(authtoken.Authenticator) + r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) + r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) + r.With(middleware.NewUserSuccessLimiter(dataExportWindow)).Get("/me/export", exportHandler.Export) + }) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireSession) + r.Use(authtoken.Verifier(container.Token)) + r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies)) + r.Use(middleware.RequireSignedIn) + r.Use(authtoken.Authenticator) + r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) + r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) + r.Use(middleware.NewUserRateLimiter(accountDeleteMaxRequests, accountDeleteWindow)) + r.Delete("/me", deleteAccountHandler.Delete) + }) r.Group(func(r chi.Router) { r.Use(middleware.RequireSession) r.Use(authtoken.Verifier(container.Token)) @@ -535,7 +583,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux { r.Use(authtoken.Verifier(container.Token)) r.Mount("/article", NewArticleRouter(container, settings.TrustedProxies)) }) - r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies)) + r.Mount("/user", NewUserRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies)) r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies)) r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies)) diff --git a/docs/api.md b/docs/api.md index da7153f..8eb4b4b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,7 +33,7 @@ for example `https://cms.example/v1/article/`. | --- | --- | | Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} | | Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) | -| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) | +| Users and invites | GET /v1/user/ (`users.view`), PATCH /v1/user/me, GET /v1/user/me/export, DELETE /v1/user/me, POST /v1/user/invite (`users.invite`), DELETE /v1/user/invite/{id} (`users.invite`), POST /v1/user/invite/{id}/renew (`users.invite`), GET /v1/auth/me (with the linked `author`) | | Settings | GET /v1/settings | | Sites | GET /v1/site/, GET /v1/site/{key}/sitemap | | Authors | GET /v1/author/ | @@ -74,7 +74,7 @@ never changes the response or the change itself. | --- | --- | | `articles` | `article.create`, `article.edit`, `article.publish`, `article.unpublish`, `article.delete` | | `media` | `media.upload`, `media.rename`, `media.delete`, `media.merge`, `media.purge`, `media.variant_create`, `media.variant_update`, `media.variant_delete`, `media.variant_enable`, `media.variant_disable`, `media.variant_purge`, `media.variant_generate_all`, `media.variant_purge_image`, `media.variant_purge_all`, `media.variant_purge_unused` | -| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | +| `users` | `user.register`, `user.verify`, `user.update_profile`, `user.request_email_change`, `user.resend_email_change`, `user.cancel_email_change`, `user.confirm_email_change`, `user.export_data`, `user.delete_account` (flagged `security`), `user.change_role`, `user.remove`, `user.transfer_ownership` (flagged `security`), `invite.create`, `invite.cancel`, `invite.renew`, `invite.accept` | | `sites` | `site.create`, `site.edit`, `site.set_default`, `site.delete` | | `authors` | `author.create`, `author.edit`, `author.delete` | | `system` | `system.media_purge` (scheduled), `system.update_checked`, `system.update_downloaded`, `system.restart_requested`, `system.activity_cleanup`, `system.activity_retention_changed` | @@ -406,6 +406,95 @@ Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the ` Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and creating one with the key of a deleted type clears any stale row for that key. +## Changing the email address + +Four session-only routes under `/v1/auth` let a verified user change their own email address. A missing or invalid +cookie is 401, an API key is 403 `This action needs a real session.` and an unverified user is 403 +`You must verify your account to access this resource.` + +| Route | Body | Success | +| --- | --- | --- | +| `POST /v1/auth/email/change` | `{ "new_email": "...", "current_password": "..." }` | 200 `{ "pending_email": "" }` | +| `POST /v1/auth/email/resend` | none | 200 `{ "pending_email": "" }` | +| `DELETE /v1/auth/email/change` | none | 204, no body | +| `POST /v1/auth/email/confirm` | `{ "code": "" }` | 200 `{ "email": "" }` | + +`users.email` does not change until the link is confirmed, so the old address keeps receiving password-reset links and +the password and username keep working. A request stores `pending_email`, a bcrypt hash of a random code and an +expiration two hours ahead, then mails the raw code as a link to the new address (`verify-new-email`) and a notice to +the old one (`email-change-notice`). A new request or a resend replaces the previous code; cancelling clears it. The +link works once. + +Errors are plain text and the code is the whole body: 400 `same_email` (the new address equals the current one, +whatever its case), 403 `wrong_password`, 404 `no_pending_email_change` (resend only), 403 +`invalid_email_change_code` (nothing pending, wrong, expired, cancelled or issued to another user) and 409 +`email_unavailable` (the address belongs to another user at confirmation time; the pending change is cleared). A body +that is not JSON is 400 with the body-mismatch message, and a failed validation is 400 with the validator text +(`new_email` must be a full address, `current_password` and `code` are required). + +A request for an address that another user already holds answers exactly like a success, stores the pending change and +still sends the notice to the old address, but sends no verification mail, so the response never reveals that an account +exists. A failed send is logged without the code and does not change the response. `POST /v1/auth/email/change` and +`POST /v1/auth/email/resend` share one limit of 5 attempts per user per 15 minutes; the sixth is 429 with a +`Retry-After` header. Cancel and confirm are not limited. + +Audit actions (category `users`, source `web`): `user.request_email_change`, `user.resend_email_change` and +`user.confirm_email_change` are flagged `security`; `user.cancel_email_change` is not, and cancelling with nothing +pending records nothing. A refused request, resend or confirm records a failed event of the same action, flagged `security`, with a specific reason. No email address is written into an event, and a wrong password records a failed +`user.request_email_change` with the reason `Wrong password.` + +## Exporting your own data + +`GET /v1/user/me/export` answers a ZIP of the caller's personal data. It needs a real, verified session: no cookie is 401, +an API key is 403 "This action needs a real session." and an unverified account is 403. There is no body and no permission +gate, since it only reads the caller's own data. The success answer is 200 with `Content-Type: application/zip`, +`Cache-Control: no-store` and `Content-Disposition: attachment; filename="renewcms-export--.zip"`; +the username is lowercased and reduced to `a-z`, `0-9` and `-`, and the date is the UTC day of the export. The archive is +built in memory first, so a failure is a plain text 500 and never a half file. + +| File | Content | +| --- | --- | +| `profile.json` | `id`, `username`, `display_name`, `email`, `pending_email`, `role {id, name}`, `is_verified`, `created_at`, `updated_at`, `author` (the linked author profile or `null`), `api_keys [{id, name, prefix, created_at, last_used_at, revoked_at}]` | +| `authored-content.json` | `articles [{id, title, body, slug, site_id, is_online, created_at, updated_at, authors}]` created by the caller (drafts included) or published and crediting their author profile, with `authors [{id, name, slug}]`, and `entries [{id, content_type, data, created_at, updated_at}]` created by the caller; soft-deleted articles are left out | +| `uploaded-media.json` | `images [{id, filename, mime, size, hash, uploaded_at}]`: references only, no file bytes; soft-deleted images are left out | +| `account-activity.json` | `events [{id, created_at, action, category, target, outcome, security, source, ip, user_agent, params}]` where the caller is the actor, without events whose target is another user, an invite or another person's author profile | + +The archive never holds the password hash, a verification, reset or email-change code, the token version or an API-key +hash. One export succeeds per user every 15 minutes; a second attempt is 429 "Too many requests. Please try again later." +with a `Retry-After` header in seconds. An export that fails with a 500 does not use the slot. A successful export records +one `user.export_data` audit event (category `users`, not flagged `security`). + +The archive is built in memory. At most 2 exports are built at the same time across all users (the constant +`maxConcurrentDataExports` in `api/dependecyInjection.go`); another request waits for a free slot. If the request is +cancelled or times out while waiting, the answer is 503 "Service unavailable" and the user's 15-minute slot is not used. + +## Deleting your own account + +`DELETE /v1/user/me` deletes the caller's account and personal data. It needs a real, verified session: no cookie is 401, +an API key is 403 "This action needs a real session." and an unverified account is 403. It is not `DELETE /v1/user/{id}`, +which stays the admin action and is unchanged. + +Body: `{"current_password": "...", "confirmation": "DELETE"}`. The password is required, and `confirmation` must be exactly +`DELETE` (case-sensitive, no spaces). The checks run in this order: + +| Answer | Body | When | +| --- | --- | --- | +| 400 | the validator message, or "The request cannot be processed due to a mismatch in the format of the body." | the body is not JSON, the password is empty or `confirmation` is not `DELETE` | +| 403 | `wrong_password` | the password is not the account's | +| 409 | `last_admin`, with the header `X-Last-Role: Owner` or `X-Last-Role: Admin` | the caller holds the built-in Owner role and no other verified user does, or the built-in Admin role and no other verified user holds Owner or Admin. Other roles, custom ones included, are never blocked. | +| 409 | `account_not_deletable` | the holders of the role changed between the check and the removal; nothing was removed | +| 429 | "Too many requests. Please try again later." with `Retry-After` | more than 5 attempts per user in 15 minutes, counted before any check | +| 500 | "Internal server error" | the removal failed; everything is rolled back | +| 204 | none, and a `Set-Cookie` that expires `jwt` | the account is deleted | + +The removal is one atomic operation: the user row, all the user's API keys, the linked author (deleted when no article +credits it, otherwise kept as "Deleted user" with the slug `deleted-user-`, no bio, avatar, website or links and +no user; the avatar image is deleted too, with its stored file after the commit, unless something else uses it), and the user's past activity events (actor and target names become "Deleted user", the IP address, user agent, `params` and `changes` are emptied, and author events on the user's own profile lose the profile name; the events stay). Articles, entries and images keep the old user id. The current session is revoked, and +one `account-deleted` mail (the site address only, no personal data) is sent to the address the account had; a failed send +is logged and does not change the 204. A successful deletion records one `user.delete_account` event (category `users`, +flagged `security`, actor and target named "Deleted user", no email in the params); a refused attempt records a failed +`user.delete_account` event with the reason `Wrong password.`, `Last Owner.` or `Last administrator.`. + ## Users, invites and ownership A user has exactly one stored role, `role_id`, and nothing else: there is no `role` string any more (the @@ -413,7 +502,7 @@ A user has exactly one stored role, `role_id`, and nothing else: there is no `ro `{ id, name, built_in, locked }`. It appears as `access.role` in `GET /v1/auth/me`, as `role` on user and invite items (`null` for a user whose role cannot be resolved) and in the responses below. `PublicUser` is exactly `id`, `username`, `email`, `is_verified`, `created_at`, `updated_at` and `display_name` in every response that includes a user (login, -verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `author` and `access`. +verify, register, invite accept, `GET /v1/auth/me`); `GET /v1/auth/me` adds `pending_email` (string or `null`), `author` and `access`. `role_assigned_by` is `{ id, name }` or `null`: who last set the user's role. It is stored as the nullable column `role_assigned_by_id` on the user (no foreign key, no backfill, existing rows are `NULL`) and the name is never stored: it diff --git a/docs/privacy.md b/docs/privacy.md index fe10885..903192a 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -15,14 +15,15 @@ Readers of your public site are not tracked by RenewCMS: the public routes set n | Data | Where | Kept | |---|---|---| -| Username, email, display name, role, bcrypt password hash | `users` table | Until the account is deleted (see [Gaps](#gaps)). | +| Username, email, display name, role, bcrypt password hash | `users` table | Until the user deletes the account (see Erasure under [Answering requests from the people concerned](#answering-requests-from-the-people-concerned)). | | Verification and password-reset codes | `users` table, bcrypt-hashed | Verification: until the account is verified. Reset: one hour, then useless; cleared when used. | +| Pending email change: the new address (`pending_email`), its bcrypt-hashed code (`email_change_code`) and the expiry (`email_change_expiration`) | `users` table | Two hours, then useless; cleared when confirmed, cancelled or replaced. | | Unverified sign-ups | `users` table | Deleted automatically 7 days after their verification code expired. | | Invites: invitee email, hashed code, inviter | `invites` table | Deleted automatically 30 days after they expired or were accepted. | | Sessions | The `jwt` cookie in the user's browser, valid 24 hours | Logging out revokes the session on the server; the revocation (a random id and an expiry, nothing else) is deleted once the session would have expired anyway. | | API keys: name, prefix, SHA-256 hash, creator, last use | `api_keys` table | Until revoked. A revoked key stays listed. | | Activity log: who (user id and name, API key name, or the username typed at a failed login), what, when, IP address, browser (user agent, truncated to 255 characters) | `audit_events` table | Settings → Activity: 30, 90 (default), 365 days or forever, and an optional size cap. Cleaned up once a night, at 03:00 server time. | -| Authors: name, slug, bio, avatar, website, social links; the linked user's email is shown to admins | `authors` table | Until deleted. | +| Authors: name, slug, bio, avatar, website, social links; the linked user's email is shown to admins | `authors` table | Until deleted. When the linked user deletes their account, the author is deleted if no article credits it, otherwise it is kept as "Deleted user" with no bio, avatar, website or links. | | Articles and custom entries | `articles`, `entries` tables | Deleted articles are soft-deleted: the row and its content stay in the database (see [Gaps](#gaps)). | | Uploaded images | `UPLOAD_DIR` or the S3 bucket, plus the `images` table (file name, size, uploader) | Until deleted from the media library. Deleted images are soft-deleted in the table; the file itself is removed. | @@ -45,7 +46,7 @@ RenewCMS sets no analytics, advertising or third-party cookie, so no consent ban | Destination | When | What | |---|---|---| -| Your SMTP provider (`SMTP_HOST`) | Sign-up, invite, password reset | Recipient address, the message (with a one-time link). STARTTLS or implicit TLS is required for any non-loopback host unless you set `SMTP_ALLOW_INSECURE=true`. | +| Your SMTP provider (`SMTP_HOST`) | Sign-up, invite, password reset, email change, account deleted | Recipient address, the message (with a one-time link, except the account-deleted notice, which holds the site address only). STARTTLS or implicit TLS is required for any non-loopback host unless you set `SMTP_ALLOW_INSECURE=true`. | | Your S3 provider, if `STORAGE_DRIVER=s3` | Every upload and image read | The image files. | | `UPDATE_FEED_URL` (default `https://git.floriansylvain.fr`) | Only when the owner clicks "Check for updates" or downloads one | The server's IP address and a standard HTTP request. No account or content data. | @@ -62,16 +63,15 @@ The back office loads nothing from third parties: fonts, icons and scripts are s ## Answering requests from the people concerned -- **Access and portability (art. 15 and 20)**: there is no self-service export yet. The data of a user is their row in `users`, their author profile, their API keys and the activity events where they are the actor; an owner can export the activity log as CSV from the Activity page and filter it by person. -- **Rectification (art. 16)**: users can change their display name and author profile themselves. Changing an email address currently needs a database edit. -- **Erasure (art. 17)**: an unverified account can cancel its own registration. Deleting a verified user has no button yet; see [Gaps](#gaps). +- **Access and portability (art. 15 and 20)**: every verified user downloads a copy of their data from the Your data card on Profile (`GET /v1/user/me/export`). It is a ZIP of four pretty-printed JSON files: `profile.json` (account, role, author profile, API-key metadata without the key or its hash), `authored-content.json` (every article they created, drafts included, the published articles crediting their author profile, and entries they created; credited authors appear as id, name and slug only; other people's drafts are never included), `uploaded-media.json` (references to the files they uploaded, no bytes) and `account-activity.json` (the events where they are the actor). It never holds the password hash, any code, the token version or other people's data, and it leaves out anonymous failed-login events (their actor is not an account), events whose target is another user, an invite or another person's author profile, and soft-deleted articles and images. The archive is built in memory, and at most 2 exports are built at the same time across all users; a request whose wait for a free slot is cancelled or times out answers 503 and does not use the user's slot. One export is allowed per user every 15 minutes, and each one is recorded as `user.export_data`. An owner can also export the activity log as CSV from the Activity page and filter it by person. +- **Rectification (art. 16)**: users can change their display name, author profile and email address themselves. An email change needs the current password and a link sent to the new address; the old address is told about the request and keeps working until the link is opened. +- **Erasure (art. 17)**: every verified user deletes their own account from the Danger zone card on Profile (`DELETE /v1/user/me`), with their password and the typed word `DELETE`; an unverified account can still cancel its own registration. The last Owner, and the last administrator (built-in Admin role with no Owner), are refused until another verified user holds the role, so the workspace is never left without one. In one atomic operation, **removed**: the user row (username, display name, email, password hash, codes), every API key, every session, and the linked author profile when no article credits it. An author profile credited on articles is **scrubbed**, not deleted: it becomes "Deleted user" with the slug `deleted-user-` (that prefix is reserved, so nobody can pick it), no bio, website, social links or user link. Its **avatar image** is deleted (the media row in the same operation, the stored file and its generated variants right after the commit; a storage failure is logged and does not undo the deletion) unless the image is also used by an article (body, cover or social image), another author, a site's default social image or a custom entry, in which case it stays. In the **activity log** the events where the user is the actor or the target lose their name (it becomes "Deleted user"), their IP address and browser, and their `params` and `changes` (which can hold display names, author names, bios and invitee emails); author events the user made on their own profile also lose the profile name. The events themselves (action, outcome, time) stay, and so do author events another person made on that profile, and events written by other people about the user, which keep their text but not the user's name. A mail "Your RenewCMS account has been deleted" (the site address only) is sent to the address the account had, and the deletion is recorded as a `user.delete_account` event under "Deleted user", with the IP address and browser of the request: it is the one trace of the erasure. + What remains, honestly: articles, entries and media the user created keep the old user id in `created_by` / `updated_by` / `uploaded_by_id` (shown as "Deleted user"; the id no longer points to anyone); `invites.invited_by` can point at the deleted id; anonymous failed-login events keep the username that was typed (see [Gaps](#gaps)); soft-deleted articles and images stay as rows (see [Gaps](#gaps)); and the browser of the person may still hold unsaved drafts and interface choices in `localStorage` / `sessionStorage` on their device, which RenewCMS cannot reach. ## Gaps These are known and tracked; until they are fixed, handle them by hand. -- No way to delete a verified user or remove an admin from the interface. -- No self-service export or email change. - Soft-deleted articles and images keep their rows indefinitely. - The username typed at a failed login is stored as typed, so a password typed in the wrong field can end up in the activity log; clean such events by hand if it happens. - Images uploaded before metadata stripping keep their metadata. diff --git a/features/account_change_email.feature b/features/account_change_email.feature new file mode 100644 index 0000000..f40da33 --- /dev/null +++ b/features/account_change_email.feature @@ -0,0 +1,292 @@ +Feature: Change my email address from Profile, verified by a link sent to the new address + + As a verified RenewCMS user + I want to change my email address with my current password and a link sent to the new address + So that I keep control of my account address and nobody can take it over without my password + or without access to the new inbox + + Background: + Given the design of record is docs/design/screens/account.md "Change email" and "Verification landing", + docs/design/routes.md "Account", the board "RenewCMS Screens Account" group "Change email" (14 states) + and the board "RenewCMS Screens Auth" group "Verify new email" (7 states) + And four endpoints are added under /v1/auth, each behind RequireSession, authtoken.Verifier, + AuditContext, RequireSignedIn, authtoken.Authenticator, RequireCurrentToken and RequireVerifiedUser, + so a missing or invalid jwt cookie gives 401 "You must be logged in to access this resource.", + an API key gives 403 "This action needs a real session." and an unverified user gives + 403 "You must verify your account to access this resource.": + | method | path | body | success | + | POST | /v1/auth/email/change | {"new_email": "...", "current_password": "..."} | 200 {"pending_email": ""} | + | POST | /v1/auth/email/resend | none | 200 {"pending_email": ""} | + | DELETE | /v1/auth/email/change | none | 204, no body | + | POST | /v1/auth/email/confirm | {"code": ""} | 200 {"email": ""} | + And POST change and POST resend share one per-user limiter of 5 attempts per 15 minutes + (middleware.NewUserRateLimiter); the sixth answers 429 "Too many requests. Please try again later." + with a Retry-After header; DELETE and confirm are not limited + And the request struct uses validate tags checked with validator.Validate.Struct: + new_email "required,email" (same email rule as the invite request, extended in validator.InitValidator) + and current_password "required"; there is no hand-written validation in the handler + And error bodies are plain text from http.Error, snake_case codes are the whole body: + 403 "wrong_password", 400 "same_email", 404 "no_pending_email_change", 403 "invalid_email_change_code", + 409 "email_unavailable"; success bodies are JSON + And the user domain gains PendingEmail, EmailChangeCode (bcrypt hash of a random UUID, like the reset + code) and EmailChangeExpiration on user.User and the users table (portable across SQLite, MySQL and + PostgreSQL), the sentinels ErrWrongPassword, ErrSameEmail, ErrNoPendingEmailChange, + ErrInvalidEmailChangeCode and ErrEmailUnavailable, and repository methods that set, clear and + confirm a pending change; confirming is one atomic step that re-checks the address is free + And a use case (RequestEmailChange, ResendEmailChange, CancelEmailChange, ConfirmEmailChange) owns + every rule; the handler never touches a repository, GORM or the mailer + And password checking uses the domain hash.Hasher (never bcrypt in the handler) + And the link code lives 2 hours and works once; users.email is untouched until confirmation, so the + old address keeps receiving password-reset links and the password and username keep working + And GET /v1/auth/me gains "pending_email" (string or null) so the Pending state survives a reload + And three mail templates are built from docs/design/source/emails and added to + internal/infrastructure/mailer/templates: + | template | to | data | subject | + | verify-new-email | the new address | Host, Username, VerificationCode | Confirm your new RenewCMS email address | + | email-change-notice| the old address | Host, Username, NewEmail | Your RenewCMS email address change was requested | + And Host is PUBLIC_URL and Username is the account's username + And the audit actions user.request_email_change, user.resend_email_change, user.cancel_email_change and + user.confirm_email_change are added (category users, source web); request, resend and confirm are + security events; no email address is written into an event + And the layering rule holds and no comment is added anywhere + + Rule: Requesting a change + + Background: + Given the user "marta" (id 7, verified) has email "marta@renew.example" and the password "limewash2026" + + Scenario: A valid request stores a pending change and sends two mails + When marta sends POST /v1/auth/email/change with + {"new_email": "marta.alves@atelier-renew.example", "current_password": "limewash2026"} + Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"} + And users.email is still "marta@renew.example" + And PendingEmail is "marta.alves@atelier-renew.example", the stored code is a hash (never the raw + code) and the expiration is within 5 seconds of now plus 2 hours + And one mail is sent to "marta.alves@atelier-renew.example" with template "verify-new-email" and + data Host, Username "marta" and the raw VerificationCode + And one mail is sent to "marta@renew.example" with template "email-change-notice" and data Host, + Username "marta" and NewEmail "marta.alves@atelier-renew.example" + And one audit event user.request_email_change is recorded: actor marta, target user 7, success, + security, with no address in its params + + Scenario Outline: A refused request changes nothing and sends nothing + When marta sends POST /v1/auth/email/change with + Then the response is with the body "" + And PendingEmail is empty and no mail is sent + + Examples: + | body | status | text | + | {"new_email": "marta.alves@atelier", "current_password": "limewash2026"} | 400 | the validator message for new_email | + | {"new_email": "", "current_password": "limewash2026"} | 400 | the validator message for new_email | + | {"new_email": "marta.alves@atelier-renew.example", "current_password": ""} | 400 | the validator message for current_password | + | not-json | 400 | The request cannot be processed due to a mismatch in the format of the body. | + | {"new_email": "MARTA@renew.example", "current_password": "limewash2026"} | 400 | same_email | + | {"new_email": "marta.alves@atelier-renew.example", "current_password": "nope"} | 403 | wrong_password | + + Scenario: A wrong password records a failed security event + When marta sends the request with the password "nope" + Then one audit event user.request_email_change is recorded with outcome failed, security true and + failure reason "Wrong password." + + Scenario: An address already in use answers like success and sends no verification mail + Given another user has the email "ines@renew.example" + When marta sends POST /v1/auth/email/change with new_email "Ines@Renew.example" + Then the status, the body and every header except Date are identical to the valid request + And a pending change for "Ines@Renew.example" is stored so a reload cannot tell the difference + And no mail is sent to "ines@renew.example" + And the email-change-notice is still sent to "marta@renew.example" + + Scenario: A new request replaces the previous link + Given marta requested a change to "a@renew.example" and received code 1 + When she requests "b@renew.example" + Then only code 2 confirms, code 1 answers 403 "invalid_email_change_code", and PendingEmail is "b@renew.example" + + Scenario: A failed send is logged and the response is unchanged + Given the mail repository fails on Send with "smtp: connection refused" + When marta sends a valid request + Then the response is 200 with the same body, the pending change is stored, and the process log names + the failed send and the error but not the raw code + And a 500 would reveal whether the address is taken, which is why the failure is not surfaced + + Scenario: The sixth request in 15 minutes is rate limited + Given marta made five requests or resends in the last 15 minutes + When she sends one more + Then the response is 429 "Too many requests. Please try again later." with a Retry-After header + And no mail is sent and nothing is stored + + Rule: Resending and cancelling while pending + + Scenario: Resend issues a new code and invalidates the old one + Given marta has a pending change to "marta.alves@atelier-renew.example" with code 1 + When she sends POST /v1/auth/email/resend + Then the response is 200 with {"pending_email": "marta.alves@atelier-renew.example"} + And one mail with template "verify-new-email" carries a new code, the expiration is reset to now plus 2 hours + And code 1 answers 403 "invalid_email_change_code" + And no email-change-notice is sent again + And one audit event user.resend_email_change is recorded + + Scenario: Resend for a taken address sends nothing + Given marta's pending address belongs to another user + When she sends POST /v1/auth/email/resend + Then the response is 200 with the same body and no mail is sent + + Scenario: Resend with nothing pending + When marta sends POST /v1/auth/email/resend + Then the response is 404 "no_pending_email_change" + + Scenario: Cancel clears the pending change + Given marta has a pending change + When she sends DELETE /v1/auth/email/change + Then the response is 204 and PendingEmail, code and expiration are empty + And the old link answers 403 "invalid_email_change_code" + And one audit event user.cancel_email_change is recorded + + Scenario: Cancel with nothing pending is harmless + When marta sends DELETE /v1/auth/email/change + Then the response is 204 and no audit event is recorded + + Rule: Confirming from the link + + Scenario: A valid code changes the email and is single use + Given marta has a pending change to "marta.alves@atelier-renew.example" with a live code + When she sends POST /v1/auth/email/confirm with that code + Then the response is 200 with {"email": "marta.alves@atelier-renew.example"} + And users.email is "marta.alves@atelier-renew.example", the pending fields are empty + And her session keeps working (token_version is unchanged) + And one audit event user.confirm_email_change is recorded + When she sends the same code again + Then the response is 403 "invalid_email_change_code" + + Scenario Outline: A code that does not work answers one 403 + Given + When marta confirms with + Then the response is 403 "invalid_email_change_code" and users.email is unchanged + + Examples: + | setup | code | + | nothing is pending | any code | + | the code expired 1 second ago | the right code | + | a pending change exists | a wrong code | + | the code was issued to another user | the right code of ines | + | the change was cancelled | the old code | + + Scenario: The address was taken in the meantime + Given marta has a live code for "ines@renew.example" and the address now belongs to another user + When she confirms + Then the response is 409 "email_unavailable", users.email is unchanged and the pending change is cleared + And the body is the same for an address that was always taken at request time + + Scenario: An empty code is refused before any lookup + When marta sends {"code": ""} + Then the response is 400 with the validator message and nothing changes + + Rule: Profile, the Account card (SPA) + + Background: + Given /profile (docs/design/screens/profile.md, features/profile_page.feature) shows the Email + field and a link "Change email"; the order of the cards is Display name, Account, Your role, + Author profile, Password, Your data, Danger zone + And the session carries pending_email from GET /v1/auth/me + And the global handling of 403 in useApi passes a 403 with body "wrong_password" through to the + caller; the form reads the body text and never the status alone + And every new SPA string (the Account card states, toasts and errors, and the verification landing + states) is added to the i18n catalogue (messages.ts, features/spa_internationalization.feature) in + English and in French under the same keys, and the English wording is the design's verbatim + And the French wording is an assumption: docs/design has no French copy, so the coder authors it and + flags it for the user's review + And the mail templates verify-new-email and email-change-notice are English only, because the existing + mailer does not localise (verification, password-reset and invitation hard-code lang="en" and the + subjects are fixed English strings) + And every state below is one Vitest spec and one row of `task visual:check -- account`, named as in + the board, using the sample data of the frame (marta, marta@renew.example, + marta.alves@atelier-renew.example) + + Scenario Outline: Form states + Given the user is on /profile + When + Then the state "" is visible with + And + + Examples: + | state | trigger | copy | requests | + | Default | the page loads, nothing pending | the Email field and the link "Change email" | none | + | Form open | "Change email" is activated | "New email address", "Current password", the hint "We'll send a verification link to the new address. Your email stays the same until you open it.", "Cancel", "Send verification link"; focus on the email field | none | + | Invalid format | "marta.alves@atelier" is typed and the field blurs or is submitted | "Enter a full email address, like name@example.com." | none | + | Same as current | "marta@renew.example" is submitted | "That's already your email address." | none | + | Password missing | submitted with an empty password | "Enter your current password.", focus on the password | none | + | Wrong password (403) | POST answers 403 "wrong_password" | "That password isn't correct. Try again." under the password, password cleared and focused | one POST | + | Sending | POST has not resolved | fields and button disabled, button reads "Sending…" | one POST | + | Rate limited (429) | POST answers 429 | "Too many requests. Try again in a few minutes.", "Send verification link" disabled | one POST | + | Server or network error | POST answers 500 or fails | "Something went wrong on our side. Nothing was changed. Try again in a moment." and the typed values kept | one POST | + + Scenario: Pending, from a successful request + When POST /v1/auth/email/change answers 200 with pending_email "marta.alves@atelier-renew.example" + Then the form closes and the Email field still shows "marta@renew.example" + And the card shows "Waiting for verification", "We sent a link to marta.alves@atelier-renew.example. Until you open it, you keep logging in with marta@renew.example.", "Resend link" and "Cancel change" + And a toast "Verification link sent" / "Check marta.alves@atelier-renew.example. The link works once, within 2 hours." appears + And reloading /profile with pending_email set shows the same Pending card without a toast + + Scenario Outline: Pending actions + Given the Pending card is shown + When + Then + + Examples: + | trigger | outcome | + | "Resend link" is activated, in flight | state "Pending: resending": "Sending…" and the button disabled | + | the resend answers 200 | state "Pending: resent": toast "Link sent again" / "The previous link no longer works." | + | the resend answers 429 with Retry-After 270 | state "Pending: resend rate limited (429)": "Resend in 4:30" disabled, "Too many requests. Try again in a few minutes."; the count runs down from the header, not the wall clock | + | "Cancel change" is activated | DELETE /v1/auth/email/change with no confirmation step, then state "Cancelled": the Default state and toast "Email change cancelled" / "You keep logging in with marta@renew.example." | + + Rule: The verification landing /account/email/confirm?c=CODE (SPA) + + Background: + Given the route is a session route inside AuthCard; without a session it shows the inline login as + /register/validate does, then continues the confirmation + And the AuthCard states are those of the board group "Verify new email" + + Scenario Outline: Landing states + Given the user opens /account/email/confirm + When + Then the state "" shows + + Examples: + | query | trigger | state | copy | + | ?c=abc123 | POST /v1/auth/email/confirm unresolved | Confirming | "Confirming your new email…" | + | ?c=abc123 | the POST answers 200 {"email": "marta.alves@atelier-renew.example"} | Success | "Your email address has changed", "Your account now uses marta.alves@atelier-renew.example. Use it the next time you log in.", "Go to profile"; toast "Email address changed" / "Your account now uses marta.alves@atelier-renew.example."; the session email is replaced | + | ?c=abc123 | the POST answers 403 | Invalid or expired (403) | "This link doesn't work", "It may have expired (links last 2 hours), been cancelled or already been used.", "Request a new link from your profile.", "Go to profile" | + | ?c=abc123 | the POST answers 409 "email_unavailable" | Address unavailable (409) | "We couldn't change your email address", "This address can't be used for your account. Go back to your profile and try a different one.", "Go to profile" | + | ?c=abc123 | no session (401 from /auth/me) | No session (401) | "Log in to confirm your new email", "You opened this link somewhere you're not logged in. Log in here and we'll confirm the change right after.", "Log in and confirm" | + | | no c parameter | Missing code | "This link is incomplete", "The confirmation code is missing. Open the link from your email again, or copy the full address into your browser.", "Go to log in"; no request is sent | + | ?c=abc123 | the POST answers 500 or fails | Server error | "Couldn't confirm the change", "Something went wrong on our side. Nothing was changed. Try again in a moment.", "Try again", which sends the POST again | + + Scenario: Log in and confirm + Given the "No session (401)" state is shown + When the user logs in inline with valid credentials + Then POST /v1/auth/email/confirm is sent once with the code and the Success state follows + And the code is not written to the URL history or logs by the SPA + + Scenario: A confirmation is not repeated by reload noise + Given the Success state was reached + When the user reloads the page + Then the POST is sent again and the "Invalid or expired (403)" state is shown, because the code is single use + + Rule: Mail templates + + Scenario Outline: Each template renders from its data + When the mailer renders "