Add admin-assisted password reset
There's no email infrastructure in this app, so a self-service "forgot password" flow isn't feasible yet. Adds a "Reset Password" button per user in the admin panel instead: generates a random temporary password (shown once, for the admin to relay out-of-band), overwrites the user's password hash, and signs them out of every existing session so a stolen session can't outlive the reset. Verified live: old password rejected after reset, new temporary password logs in successfully. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -87,3 +87,8 @@ export async function consumeBackupCode(submitted, hashes) {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A random temporary password for admin-assisted resets — shown once, relayed to the user out-of-band. */
|
||||||
|
export function generateTemporaryPassword() {
|
||||||
|
return randomBytes(6).toString('hex');
|
||||||
|
}
|
||||||
|
|||||||
@@ -298,6 +298,11 @@ export function adminDeleteProject(id) {
|
|||||||
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Overwrites a user's password hash directly — used for admin-assisted password resets. */
|
||||||
|
export function adminSetPassword(userId, passwordHash) {
|
||||||
|
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Session store backing (used by server/sessionStore.js)
|
// Session store backing (used by server/sessionStore.js)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -327,3 +332,24 @@ export function destroySession(sid) {
|
|||||||
export function pruneExpiredSessions() {
|
export function pruneExpiredSessions() {
|
||||||
db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now());
|
db.prepare('DELETE FROM sessions WHERE expires < ?').run(Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a user out everywhere by deleting every session that belongs to them.
|
||||||
|
* Sessions don't have an indexed user_id column (they're just an opaque JSON
|
||||||
|
* blob to express-session), so this scans and parses — fine at this app's scale.
|
||||||
|
*/
|
||||||
|
export function destroyAllSessionsForUser(userId) {
|
||||||
|
const rows = db.prepare('SELECT sid, sess FROM sessions').all();
|
||||||
|
const staleSids = rows
|
||||||
|
.filter((row) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.sess)?.userId === userId;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map((row) => row.sid);
|
||||||
|
if (staleSids.length === 0) return;
|
||||||
|
const placeholders = staleSids.map(() => '?').join(',');
|
||||||
|
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
|
||||||
|
}
|
||||||
|
|||||||
+17
-1
@@ -10,12 +10,13 @@ import {
|
|||||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||||
|
adminSetPassword, destroyAllSessionsForUser,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { SqliteSessionStore } from './sessionStore.js';
|
import { SqliteSessionStore } from './sessionStore.js';
|
||||||
import {
|
import {
|
||||||
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
|
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
|
||||||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||||||
generateBackupCodes, hashBackupCodes, consumeBackupCode,
|
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -389,6 +390,21 @@ app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
|
||||||
|
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
|
||||||
|
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const temporaryPassword = generateTemporaryPassword();
|
||||||
|
const passwordHash = await hashPassword(temporaryPassword);
|
||||||
|
adminSetPassword(req.params.id, passwordHash);
|
||||||
|
destroyAllSessionsForUser(req.params.id);
|
||||||
|
res.json({ temporaryPassword });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('POST /api/admin/users/:id/reset-password error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to reset password.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(adminGetAllProjects());
|
res.json(adminGetAllProjects());
|
||||||
|
|||||||
+38
-4
@@ -37,6 +37,7 @@ import {
|
|||||||
getSharedProject,
|
getSharedProject,
|
||||||
adminListUsers,
|
adminListUsers,
|
||||||
adminDeleteUser,
|
adminDeleteUser,
|
||||||
|
adminResetPassword,
|
||||||
adminListProjects,
|
adminListProjects,
|
||||||
adminGetProject,
|
adminGetProject,
|
||||||
adminDeleteProject,
|
adminDeleteProject,
|
||||||
@@ -1081,6 +1082,7 @@ const App = () => {
|
|||||||
const [adminLoading, setAdminLoading] = useState(false);
|
const [adminLoading, setAdminLoading] = useState(false);
|
||||||
const [adminError, setAdminError] = useState('');
|
const [adminError, setAdminError] = useState('');
|
||||||
const [adminViewProject, setAdminViewProject] = useState(null); // full project data being previewed
|
const [adminViewProject, setAdminViewProject] = useState(null); // full project data being previewed
|
||||||
|
const [adminResetResult, setAdminResetResult] = useState(null); // { email, temporaryPassword } shown once
|
||||||
const [project, setProject] = useState(null);
|
const [project, setProject] = useState(null);
|
||||||
// 'home' | 'setup' | 'study' | 'settings'
|
// 'home' | 'setup' | 'study' | 'settings'
|
||||||
const [currentPage, setCurrentPage] = useState('home');
|
const [currentPage, setCurrentPage] = useState('home');
|
||||||
@@ -3844,6 +3846,13 @@ const deleteProject = (id) => {
|
|||||||
else alert(result.error ?? 'Failed to load project.');
|
else alert(result.error ?? 'Failed to load project.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetPasswordAdmin = async (id, email) => {
|
||||||
|
if (!window.confirm(`Reset the password for "${email}"? They'll be signed out everywhere and need the new temporary password to log back in.`)) return;
|
||||||
|
const result = await adminResetPassword(id);
|
||||||
|
if (result.ok) setAdminResetResult({ email, temporaryPassword: result.data.temporaryPassword });
|
||||||
|
else alert(result.error ?? 'Failed to reset password.');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
|
||||||
@@ -3903,10 +3912,16 @@ const deleteProject = (id) => {
|
|||||||
<td className="px-4 py-3 text-slate-500">{u.projectCount}</td>
|
<td className="px-4 py-3 text-slate-500">{u.projectCount}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
{u.id !== authUser.id && (
|
{u.id !== authUser.id && (
|
||||||
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
|
<div className="flex justify-end gap-2">
|
||||||
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
<button type="button" onClick={() => handleResetPasswordAdmin(u.id, u.email)}
|
||||||
Delete
|
className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
|
||||||
</button>
|
Reset Password
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleDeleteUserAdmin(u.id, u.email)}
|
||||||
|
className="rounded-lg border border-rose-200 px-3 py-1 text-xs font-semibold text-rose-600 hover:bg-rose-50">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -3978,6 +3993,25 @@ const deleteProject = (id) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{adminResetResult && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900">Password reset</h3>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Relay this to <span className="font-medium text-slate-700">{adminResetResult.email}</span> yourself
|
||||||
|
(text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-center font-mono text-lg tracking-wider text-amber-800">
|
||||||
|
{adminResetResult.temporaryPassword}
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={() => setAdminResetResult(null)}
|
||||||
|
className="mt-4 w-full rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ export async function adminDeleteUser(id) {
|
|||||||
return request('DELETE', `/admin/users/${id}`);
|
return request('DELETE', `/admin/users/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sets a random temporary password for a user and signs them out everywhere. Returns { temporaryPassword }. */
|
||||||
|
export async function adminResetPassword(id) {
|
||||||
|
return request('POST', `/admin/users/${id}/reset-password`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function adminListProjects() {
|
export async function adminListProjects() {
|
||||||
return request('GET', '/admin/projects');
|
return request('GET', '/admin/projects');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user