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.
97 lines
3.0 KiB
TypeScript
97 lines
3.0 KiB
TypeScript
'use server';
|
|
|
|
import { cookies, headers } from 'next/headers';
|
|
import { hashPassword, createSession } from '@/lib/auth';
|
|
import { prisma } from '@/lib/prisma';
|
|
import { getInstanceSettings } from '@/lib/instanceSettings';
|
|
import { ensureLibraryForUser } from '@/lib/library';
|
|
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
|
|
|
|
const EMAIL_RE = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
|
|
|
|
export async function signupAction(
|
|
email: string,
|
|
password: string,
|
|
passwordConfirm: string,
|
|
name?: string,
|
|
) {
|
|
try {
|
|
// Per-IP cap: 5 sign-ups per 15 minutes. Generous enough for
|
|
// legitimate use (testing, household members coming online together)
|
|
// but cuts off automated account spraying.
|
|
const hdrs = await headers();
|
|
const ip = clientIpFromHeaders(hdrs);
|
|
const limit = rateLimit(`signup:${ip}`, { limit: 5, windowMs: 15 * 60_000 });
|
|
if (!limit.ok) {
|
|
return {
|
|
error: `Too many sign-up attempts. Try again in ${limit.retryAfterSec}s.`,
|
|
};
|
|
}
|
|
|
|
const settings = await getInstanceSettings();
|
|
if (!settings.signupsOpen) {
|
|
return { error: 'New sign-ups are not enabled on this instance.' };
|
|
}
|
|
|
|
if (!EMAIL_RE.test(email)) {
|
|
return { error: 'Enter a valid email address.' };
|
|
}
|
|
if (password.length < 8) {
|
|
return { error: 'Password must be at least 8 characters.' };
|
|
}
|
|
if (password !== passwordConfirm) {
|
|
return { error: 'Passwords do not match.' };
|
|
}
|
|
|
|
const existing = await prisma.user.findUnique({ where: { email } });
|
|
if (existing) {
|
|
// Don't leak existence — generic message keeps probing harder.
|
|
return { error: 'Could not create account with that email.' };
|
|
}
|
|
|
|
const passwordHash = await hashPassword(password);
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash,
|
|
name: name?.trim() || null,
|
|
isAdmin: false,
|
|
userPreferences: {
|
|
create: {
|
|
theme: 'system',
|
|
defaultWeightUnit: 'lbs',
|
|
defaultRestSeconds: 90,
|
|
enableClaudeAI: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Seed the curated exercise library for the new user immediately so they
|
|
// see exercises on first load. The boot-time ensure step would do this
|
|
// on next restart anyway, but we don't want them to wait.
|
|
await ensureLibraryForUser(user.id);
|
|
|
|
const session = await createSession(user.id);
|
|
const cookieStore = await cookies();
|
|
cookieStore.set('sessionToken', session.token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24 * 30,
|
|
path: '/',
|
|
});
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('Signup error:', error);
|
|
return { error: 'An error occurred during sign-up.' };
|
|
}
|
|
}
|
|
|
|
export async function getSignupsOpen(): Promise<boolean> {
|
|
const settings = await getInstanceSettings();
|
|
return settings.signupsOpen;
|
|
}
|