diff --git a/server/db.js b/server/db.js index a6c940f..249ef0f 100644 --- a/server/db.js +++ b/server/db.js @@ -298,8 +298,8 @@ 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) { +/** Overwrites a user's password hash directly — used for both self-service and admin-assisted resets. */ +export function setUserPassword(userId, passwordHash) { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, userId); } @@ -337,10 +337,13 @@ export function pruneExpiredSessions() { * 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. + * Pass exceptSid to keep one session alive (e.g. the one completing a self-service + * password change, so the user isn't immediately logged out of their own action). */ -export function destroyAllSessionsForUser(userId) { +export function destroyAllSessionsForUser(userId, exceptSid = null) { const rows = db.prepare('SELECT sid, sess FROM sessions').all(); const staleSids = rows + .filter((row) => row.sid !== exceptSid) .filter((row) => { try { return JSON.parse(row.sess)?.userId === userId; diff --git a/server/index.js b/server/index.js index 86d6c7c..a99f11f 100644 --- a/server/index.js +++ b/server/index.js @@ -10,7 +10,7 @@ import { enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, getShareToken, setShareToken, clearShareToken, getProjectByShareToken, adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, - adminSetPassword, destroyAllSessionsForUser, + setUserPassword, destroyAllSessionsForUser, } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; import { @@ -187,6 +187,34 @@ app.patch('/api/auth/profile', requireAuth, (req, res) => { } }); +// --------------------------------------------------------------------------- +// POST /api/auth/change-password — self-service password change (Account Settings) +// --------------------------------------------------------------------------- +app.post('/api/auth/change-password', requireAuth, async (req, res) => { + try { + const user = getUserById(req.session.userId); + const currentPassword = String(req.body?.currentPassword ?? ''); + const newPassword = String(req.body?.newPassword ?? ''); + + const valid = await verifyPassword(currentPassword, user.passwordHash); + if (!valid) { + return res.status(401).json({ error: 'Current password is incorrect.' }); + } + if (!isValidPassword(newPassword)) { + return res.status(400).json({ error: 'New password must be at least 8 characters.' }); + } + + const passwordHash = await hashPassword(newPassword); + setUserPassword(user.id, passwordHash); + // Sign out every other session (e.g. a stolen one) but keep this one logged in. + destroyAllSessionsForUser(user.id, req.sessionID); + res.json({ ok: true }); + } catch (err) { + console.error('POST /api/auth/change-password error:', err); + res.status(500).json({ error: 'Failed to change password.' }); + } +}); + // --------------------------------------------------------------------------- // Two-factor auth setup (requires an already-authenticated session) // --------------------------------------------------------------------------- @@ -396,7 +424,7 @@ app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async try { const temporaryPassword = generateTemporaryPassword(); const passwordHash = await hashPassword(temporaryPassword); - adminSetPassword(req.params.id, passwordHash); + setUserPassword(req.params.id, passwordHash); destroyAllSessionsForUser(req.params.id); res.json({ temporaryPassword }); } catch (err) { diff --git a/src/App.jsx b/src/App.jsx index 7e33055..766ea09 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -31,6 +31,7 @@ import { confirmMfaSetup, disableMfa, updateProfile, + changePassword, getShareStatus, enableSharing, disableSharing, @@ -1066,6 +1067,10 @@ const App = () => { const [podcastNameInput, setPodcastNameInput] = useState(''); const [podcastNameSaving, setPodcastNameSaving] = useState(false); const [podcastNameSaved, setPodcastNameSaved] = useState(false); + const [changePasswordForm, setChangePasswordForm] = useState({ current: '', next: '', confirm: '' }); + const [changePasswordBusy, setChangePasswordBusy] = useState(false); + const [changePasswordError, setChangePasswordError] = useState(''); + const [changePasswordSaved, setChangePasswordSaved] = useState(false); const [exportMenuOpen, setExportMenuOpen] = useState(false); // Read-only share links const [shareToken, setShareToken] = useState(null); @@ -4067,6 +4072,25 @@ const deleteProject = (id) => { setAuthUser((u) => ({ ...u, totpEnabled: false })); }; + const submitChangePassword = async (e) => { + e.preventDefault(); + setChangePasswordError(''); + if (changePasswordForm.next !== changePasswordForm.confirm) { + setChangePasswordError('New passwords don\'t match.'); + return; + } + setChangePasswordBusy(true); + const result = await changePassword(changePasswordForm.current, changePasswordForm.next); + setChangePasswordBusy(false); + if (!result.ok) { + setChangePasswordError(result.error ?? 'Failed to change password.'); + return; + } + setChangePasswordForm({ current: '', next: '', confirm: '' }); + setChangePasswordSaved(true); + window.setTimeout(() => setChangePasswordSaved(false), 3000); + }; + const submitPodcastName = async (e) => { e.preventDefault(); setPodcastNameSaving(true); @@ -4108,6 +4132,59 @@ const deleteProject = (id) => {
+Changing your password signs you out of every other device — this one stays signed in.
+