diff --git a/server/auth.js b/server/auth.js index a445883..a08ba98 100644 --- a/server/auth.js +++ b/server/auth.js @@ -87,3 +87,8 @@ export async function consumeBackupCode(submitted, hashes) { } 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'); +} diff --git a/server/db.js b/server/db.js index 1c7511f..a6c940f 100644 --- a/server/db.js +++ b/server/db.js @@ -298,6 +298,11 @@ export function adminDeleteProject(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) // --------------------------------------------------------------------------- @@ -327,3 +332,24 @@ export function destroySession(sid) { export function pruneExpiredSessions() { 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); +} diff --git a/server/index.js b/server/index.js index 9063285..86d6c7c 100644 --- a/server/index.js +++ b/server/index.js @@ -10,12 +10,13 @@ import { enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, getShareToken, setShareToken, clearShareToken, getProjectByShareToken, adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, + adminSetPassword, destroyAllSessionsForUser, } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; import { isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail, generateTotpSecret, totpKeyUri, verifyTotpToken, - generateBackupCodes, hashBackupCodes, consumeBackupCode, + generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, } from './auth.js'; 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) => { try { res.json(adminGetAllProjects()); diff --git a/src/App.jsx b/src/App.jsx index bfca5bf..7e33055 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -37,6 +37,7 @@ import { getSharedProject, adminListUsers, adminDeleteUser, + adminResetPassword, adminListProjects, adminGetProject, adminDeleteProject, @@ -1081,6 +1082,7 @@ const App = () => { const [adminLoading, setAdminLoading] = useState(false); const [adminError, setAdminError] = useState(''); const [adminViewProject, setAdminViewProject] = useState(null); // full project data being previewed + const [adminResetResult, setAdminResetResult] = useState(null); // { email, temporaryPassword } shown once const [project, setProject] = useState(null); // 'home' | 'setup' | 'study' | 'settings' const [currentPage, setCurrentPage] = useState('home'); @@ -3844,6 +3846,13 @@ const deleteProject = (id) => { 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 (
@@ -3903,10 +3912,16 @@ const deleteProject = (id) => { {u.projectCount} {u.id !== authUser.id && ( - +
+ + +
)} @@ -3978,6 +3993,25 @@ const deleteProject = (id) => {
)} + + {adminResetResult && ( +
+
+

Password reset

+

+ Relay this to {adminResetResult.email} yourself + (text, call, in person) — it won't be shown again. They're signed out everywhere until they log in with it. +

+

+ {adminResetResult.temporaryPassword} +

+ +
+
+ )} ); } diff --git a/src/syncService.js b/src/syncService.js index 2a3dece..559f59b 100644 --- a/src/syncService.js +++ b/src/syncService.js @@ -171,6 +171,11 @@ export async function adminDeleteUser(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() { return request('GET', '/admin/projects'); }