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 @@