mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user