From 79d7d26a00c406c011fdcc9f9e00a45441a3599b Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 13 Apr 2026 20:59:35 -0400 Subject: [PATCH] feat: add admin password reset for other users (#103) * feat: add admin password reset for other users New POST /api/v1/admin/users/{id}/reset-password endpoint. When email is configured, sends a password reset link. Otherwise generates a temporary password and invalidates existing sessions. Includes audit logging via new password_reset_by_admin action and frontend UI with confirmation. * fix: treat session revocation and email send as hard failures Make session invalidation failure abort the reset instead of silently continuing, and send the reset email synchronously so delivery failures are surfaced to the admin caller. --- internal/models/activity.go | 3 +- internal/server/handlers_admin_users.go | 81 ++++++++++++++++++++ internal/server/server.go | 1 + web/src/routes/console/admin/+page.svelte | 93 ++++++++++++++++++++++- 4 files changed, 175 insertions(+), 3 deletions(-) diff --git a/internal/models/activity.go b/internal/models/activity.go index 68e70506..795a70f2 100644 --- a/internal/models/activity.go +++ b/internal/models/activity.go @@ -28,7 +28,8 @@ const ( ActionOAuthLogin = "oauth_login" ActionOAuthLoginFailed = "oauth_login_failed" ActionPlanChanged = "plan_changed" - ActionAccountDeleted = "account_deleted" + ActionPasswordResetByAdmin = "password_reset_by_admin" + ActionAccountDeleted = "account_deleted" ) type Activity struct { diff --git a/internal/server/handlers_admin_users.go b/internal/server/handlers_admin_users.go index 4202fca3..38252b93 100644 --- a/internal/server/handlers_admin_users.go +++ b/internal/server/handlers_admin_users.go @@ -1,6 +1,8 @@ package server import ( + "crypto/rand" + "encoding/hex" "encoding/json" "net/http" "strconv" @@ -241,6 +243,85 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { }) } +// handleAdminResetPassword force-resets a user's password. +// If email is configured, sends a reset link. Otherwise returns a temporary password. +// POST /api/v1/admin/users/{userID}/reset-password +func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } + + userID := chi.URLParam(r, "userID") + user, err := s.store.GetUser(userID) + if err != nil { + writeInternalError(w, err) + return + } + if user == nil { + writeError(w, http.StatusNotFound, "not_found", "User not found") + return + } + + if s.email != nil && s.baseURL != "" { + // Email configured: generate reset token and send link + token, err := s.store.CreatePasswordReset(user.ID) + if err != nil { + writeInternalError(w, err) + return + } + + resetURL := s.baseURL + "/reset-password/" + token + if err := s.email.SendPasswordReset(r.Context(), user.Email, user.Name, resetURL); err != nil { + writeError(w, http.StatusInternalServerError, "email_failed", "Failed to send password reset email") + return + } + + s.logAuditEvent(models.ActionPasswordResetByAdmin, r, auditMeta(map[string]string{ + "target_user_id": userID, + "method": "email", + })) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "method": "email", + "message": "Password reset email sent to " + user.Email, + }) + return + } + + // No email: generate a temporary password + raw := make([]byte, 16) + if _, err := rand.Read(raw); err != nil { + writeInternalError(w, err) + return + } + tempPassword := hex.EncodeToString(raw) + + pwd := tempPassword + if _, err := s.store.UpdateUser(userID, models.UserUpdate{Password: &pwd}); err != nil { + writeInternalError(w, err) + return + } + + // Invalidate all existing sessions so the user must log in with the new password + if err := s.store.DeleteUserSessions(userID); err != nil { + writeInternalError(w, err) + return + } + + s.logAuditEvent(models.ActionPasswordResetByAdmin, r, auditMeta(map[string]string{ + "target_user_id": userID, + "method": "temporary_password", + })) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "method": "temporary_password", + "temp_password": tempPassword, + "message": "Temporary password generated. The user's existing sessions have been invalidated.", + }) +} + // --- Admin Limits Management --- // handleAdminGetLimits returns the current default plan limits. diff --git a/internal/server/server.go b/internal/server/server.go index 33ceacba..8656ae9e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -313,6 +313,7 @@ func (s *Server) setupRouter() { r.Get("/users", s.handleAdminListUsers) r.Get("/users/{userID}", s.handleAdminGetUser) r.Patch("/users/{userID}", s.handleAdminUpdateUser) + r.Post("/users/{userID}/reset-password", s.handleAdminResetPassword) // Plan limits r.Get("/limits", s.handleAdminGetLimits) diff --git a/web/src/routes/console/admin/+page.svelte b/web/src/routes/console/admin/+page.svelte index cc916320..1881e0c5 100644 --- a/web/src/routes/console/admin/+page.svelte +++ b/web/src/routes/console/admin/+page.svelte @@ -1,6 +1,6 @@