a11639cc56
Per-user password change (Settings -> Change password)
- changePasswordAction verifies current password before rotating, blocks
same-as-current, requires 8+ chars and matching confirm.
- Always revokes every other session for the user via
deleteOtherSessions(userId, currentToken). If you're rotating because
you suspect compromise, the worst-case kicks the attacker off
immediately. UI surfaces how many sessions were revoked.
- ChangePasswordForm sits between SettingsForm and AdminInstanceSettings
on the existing settings page. Available to every user, no admin
privileges required.
Admin user management (/main/admin/users — admin only)
- New page lists every account: email, name, joined date, workout count,
role. Linked from the AdminInstanceSettings panel ("Manage users ->").
- Per-row actions: Promote/Demote (toggles isAdmin), Reset password
(inline 8+ char input), Delete (cascading delete via Prisma onDelete:
Cascade — workouts, exercises, sessions, preferences all go).
- Last-admin guard: setUserAdmin and deleteUser refuse if it would
leave 0 admins. Self-delete is blocked from the admin UI (preserves
the actor's session and forces them to use a "danger zone" flow they
set up explicitly elsewhere).
- adminResetPassword force-revokes ALL of the target user's sessions —
admin reset implies the old credential is no longer trusted.
- Server actions all do their own requireAdmin() gate (defense in depth
beyond the page-level redirect).
Rate limit on /auth/login + /auth/signup
- New lib/rateLimit.ts: tiny in-process sliding-window limiter, no deps.
Map<key, timestamps[]> with cutoff filtering on each call. Per Node
process — fine for the single-replica StartOS deploy shape.
- clientIpFromHeaders prefers x-forwarded-for (leftmost), falls back to
x-real-ip, then 'unknown' (acts as a global cap in dev).
- signup: 5 attempts per IP per 15min. Cuts off automated account
spraying without blocking legitimate household-member sign-ups.
- login: 10 attempts per IP per 15min. Slows credential stuffing while
giving typo-prone users headroom.
61 lines
1.8 KiB
TypeScript
61 lines
1.8 KiB
TypeScript
'use server';
|
|
|
|
import { cookies } from 'next/headers';
|
|
import {
|
|
getCurrentUser,
|
|
hashPassword,
|
|
verifyPassword,
|
|
deleteOtherSessions,
|
|
} from '@/lib/auth';
|
|
import { prisma } from '@/lib/prisma';
|
|
|
|
/**
|
|
* Change the current user's password.
|
|
*
|
|
* Requires the current password (defends against a stolen-session
|
|
* attacker rotating the password to lock the real owner out). Always
|
|
* revokes every other session for this user — if you're rotating
|
|
* because you suspect compromise, the worst-case kicks the attacker
|
|
* off immediately.
|
|
*/
|
|
export async function changePasswordAction(
|
|
currentPassword: string,
|
|
newPassword: string,
|
|
newPasswordConfirm: string,
|
|
): Promise<{ success?: true; error?: string; revoked?: number }> {
|
|
try {
|
|
const user = await getCurrentUser();
|
|
if (!user) return { error: 'Not signed in.' };
|
|
|
|
if (newPassword.length < 8) {
|
|
return { error: 'New password must be at least 8 characters.' };
|
|
}
|
|
if (newPassword !== newPasswordConfirm) {
|
|
return { error: 'New password and confirmation do not match.' };
|
|
}
|
|
if (currentPassword === newPassword) {
|
|
return { error: 'New password must differ from current password.' };
|
|
}
|
|
|
|
const ok = await verifyPassword(currentPassword, user.passwordHash);
|
|
if (!ok) {
|
|
return { error: 'Current password is incorrect.' };
|
|
}
|
|
|
|
const newHash = await hashPassword(newPassword);
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { passwordHash: newHash },
|
|
});
|
|
|
|
const cookieStore = await cookies();
|
|
const currentToken = cookieStore.get('sessionToken')?.value ?? null;
|
|
const revoked = await deleteOtherSessions(user.id, currentToken);
|
|
|
|
return { success: true, revoked };
|
|
} catch (err) {
|
|
console.error('changePassword error:', err);
|
|
return { error: 'An error occurred while changing your password.' };
|
|
}
|
|
}
|