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.
This commit is contained in:
xarmian
2026-04-13 20:59:35 -04:00
committed by GitHub
parent f97ab766f5
commit 79d7d26a00
4 changed files with 175 additions and 3 deletions
+2 -1
View File
@@ -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 {
+81
View File
@@ -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.
+1
View File
@@ -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)
+91 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { adminFetch, adminPatch, formatDate, type AdminUser } from '$lib/stores/admin.svelte';
import { adminFetch, adminPatch, adminPost, formatDate, type AdminUser } from '$lib/stores/admin.svelte';
let users = $state<AdminUser[]>([]);
let search = $state('');
@@ -15,6 +15,10 @@
let roleConfirm = $state(false);
let roleSaving = $state(false);
let roleMsg = $state('');
let resetConfirm = $state(false);
let resetSaving = $state(false);
let resetResult = $state<{ method: string; temp_password?: string; message: string } | null>(null);
let resetError = $state('');
async function loadUsers() {
loading = true;
@@ -50,6 +54,9 @@
saveMsg = '';
roleConfirm = false;
roleMsg = '';
resetConfirm = false;
resetResult = null;
resetError = '';
}
function selectedUser(): AdminUser | undefined {
@@ -79,6 +86,22 @@
}
}
async function resetPassword() {
if (!selectedId) return;
resetSaving = true;
resetError = '';
resetResult = null;
try {
const result = await adminPost(`/admin/users/${selectedId}/reset-password`);
resetResult = result;
} catch (e) {
resetError = e instanceof Error ? e.message : 'Password reset failed';
} finally {
resetSaving = false;
resetConfirm = false;
}
}
async function saveUser() {
if (!selectedId) return;
saving = true;
@@ -210,6 +233,43 @@
{#if roleMsg}<span class="save-msg">{roleMsg}</span>{/if}
</div>
</div>
<div class="edit-field">
<span class="field-label">Password</span>
<div class="role-row">
{#if !resetConfirm && !resetResult}
<button class="btn role-btn" onclick={() => { resetConfirm = true; resetError = ''; }}>
Reset Password
</button>
{/if}
{#if resetConfirm && !resetResult}
<div class="role-confirm">
<span class="role-confirm-msg">
Send password reset for <strong>{user.name || user.username}</strong>?
</span>
<button class="btn primary" onclick={resetPassword} disabled={resetSaving}>
{resetSaving ? 'Resetting...' : 'Confirm'}
</button>
<button class="btn" onclick={() => { resetConfirm = false; }}>
Cancel
</button>
</div>
{/if}
{#if resetResult}
<div class="reset-result">
{#if resetResult.method === 'email'}
<span class="reset-success">{resetResult.message}</span>
{:else}
<div class="temp-password-result">
<span class="reset-success">Temporary password generated:</span>
<code class="temp-password">{resetResult.temp_password}</code>
<span class="reset-note">User's sessions have been invalidated. Share this password securely.</span>
</div>
{/if}
</div>
{/if}
{#if resetError}<span class="save-msg" style="color: #ef4444">{resetError}</span>{/if}
</div>
</div>
<div class="edit-field">
<label for="edit-plan">Plan</label>
<select id="edit-plan" bind:value={editPlan}>
@@ -363,7 +423,8 @@
flex-direction: column;
gap: var(--space-1);
}
.edit-field label {
.edit-field label,
.edit-field .field-label {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
@@ -416,6 +477,34 @@
font-size: 0.8rem;
padding: var(--space-1) var(--space-3);
}
.reset-result {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.reset-success {
font-size: 0.8rem;
color: var(--accent-green, #22c55e);
}
.temp-password-result {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.temp-password {
font-family: monospace;
font-size: 0.85rem;
padding: var(--space-2) var(--space-3);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
user-select: all;
}
.reset-note {
font-size: 0.75rem;
color: var(--text-muted);
}
.users-page {
display: flex;