Add self-service "change my password" option in Account Settings
New section in Settings: current password + new password + confirm, verified against the existing hash before accepting. On success it signs out every other session for that account (in case one was compromised) but keeps the current session logged in, so changing your password doesn't immediately kick you back to the login screen. Renamed the underlying db.js function (adminSetPassword -> setUserPassword) since it's now shared by both this and the existing admin-assisted reset. Verified live: old password rejected after change, new password works, current session stayed logged in throughout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+6
-3
@@ -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;
|
||||
|
||||
+30
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user