feat: account deletion

This commit is contained in:
juancwu 2026-05-17 15:01:04 +00:00
commit 2db260f849
20 changed files with 785 additions and 29 deletions

View file

@ -6,6 +6,7 @@ import (
"net/http"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/middleware"
"git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
@ -35,7 +36,54 @@ func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) {
return
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), ""))
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), fullUser.Email, "", ""))
}
func (h *settingsHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
fullUser, err := h.userService.ByID(user.ID)
if err != nil {
slog.Error("failed to fetch user for account deletion", "error", err, "user_id", user.ID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
confirmation := r.FormValue("confirm_email")
reason := r.FormValue("reason")
err = h.userService.RequestAccountDeletion(service.RequestAccountDeletionInput{
UserID: user.ID,
ConfirmationEmail: confirmation,
Reason: reason,
IPAddress: middleware.GetClientIP(r),
})
if err != nil {
slog.Warn("account deletion request failed", "error", err, "user_id", user.ID)
msg := "We couldn't queue your account for deletion. Please try again."
if errors.Is(err, service.ErrEmailConfirmationMismatch) {
msg = "The email you entered does not match your account email."
} else if errors.Is(err, service.ErrAccountAlreadyPending) {
// Race with another tab — just send them to the pending page.
http.Redirect(w, r, "/account-pending-deletion", http.StatusSeeOther)
return
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), fullUser.Email, "", msg))
return
}
slog.Info("account deletion queued", "user_id", user.ID, "email", fullUser.Email)
http.Redirect(w, r, "/account-pending-deletion", http.StatusSeeOther)
}
func (h *settingsHandler) AccountPendingDeletionPage(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context())
if user == nil || !user.IsPendingDeletion() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt))
}
func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
@ -66,12 +114,12 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {
msg = "Password must be at least 12 characters"
}
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg))
ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), fullUser.Email, msg, ""))
return
}
// Password set successfully — render page with success toast
ui.Render(w, r, pages.AppSettings(true, ""))
ui.Render(w, r, pages.AppSettings(true, fullUser.Email, "", ""))
ui.RenderToast(w, r, toast.Toast(toast.Props{
Title: "Password updated",
Variant: toast.VariantSuccess,

View file

@ -22,7 +22,7 @@ func newTestSettingsHandler(dbi testutil.DBInfo) (*settingsHandler, *service.Aut
accountSvc := service.NewAccountService(accountRepo)
emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false)
authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false, false)
userSvc := service.NewUserService(userRepo)
userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
return NewSettingsHandler(authSvc, userSvc), authSvc
}