From a11639cc5626c54046b1f9846278c507d8f5f214 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 9 May 2026 09:01:33 -0500 Subject: [PATCH] Self-serve password change, admin user management, login/signup rate limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- proof-of-work/app/auth/login/actions.ts | 14 +- proof-of-work/app/auth/signup/actions.ts | 15 +- proof-of-work/app/main/admin/users/actions.ts | 119 ++++++++++ proof-of-work/app/main/admin/users/page.tsx | 52 ++++ .../app/main/settings/changePasswordAction.ts | 60 +++++ proof-of-work/app/main/settings/page.tsx | 2 + proof-of-work/components/admin/UsersTable.tsx | 224 ++++++++++++++++++ .../settings/AdminInstanceSettings.tsx | 9 + .../settings/ChangePasswordForm.tsx | 138 +++++++++++ proof-of-work/lib/rateLimit.ts | 62 +++++ 10 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 proof-of-work/app/main/admin/users/actions.ts create mode 100644 proof-of-work/app/main/admin/users/page.tsx create mode 100644 proof-of-work/app/main/settings/changePasswordAction.ts create mode 100644 proof-of-work/components/admin/UsersTable.tsx create mode 100644 proof-of-work/components/settings/ChangePasswordForm.tsx create mode 100644 proof-of-work/lib/rateLimit.ts diff --git a/proof-of-work/app/auth/login/actions.ts b/proof-of-work/app/auth/login/actions.ts index 18bcb9b..08a2796 100644 --- a/proof-of-work/app/auth/login/actions.ts +++ b/proof-of-work/app/auth/login/actions.ts @@ -1,12 +1,24 @@ 'use server'; import { redirect } from 'next/navigation'; -import { cookies } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { verifyPassword, createSession } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; +import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit'; export async function loginAction(email: string, password: string) { try { + // Per-IP cap: 10 login attempts per 15 minutes. Slows credential + // stuffing without locking out legitimate typo-prone users. + const hdrs = await headers(); + const ip = clientIpFromHeaders(hdrs); + const limit = rateLimit(`login:${ip}`, { limit: 10, windowMs: 15 * 60_000 }); + if (!limit.ok) { + return { + error: `Too many login attempts. Try again in ${limit.retryAfterSec}s.`, + }; + } + // Look up user by email const user = await prisma.user.findUnique({ where: { email }, diff --git a/proof-of-work/app/auth/signup/actions.ts b/proof-of-work/app/auth/signup/actions.ts index 27d6ff5..47dfcf2 100644 --- a/proof-of-work/app/auth/signup/actions.ts +++ b/proof-of-work/app/auth/signup/actions.ts @@ -1,10 +1,11 @@ 'use server'; -import { cookies } from 'next/headers'; +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,}$/; @@ -15,6 +16,18 @@ export async function signupAction( 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.' }; diff --git a/proof-of-work/app/main/admin/users/actions.ts b/proof-of-work/app/main/admin/users/actions.ts new file mode 100644 index 0000000..93fb96e --- /dev/null +++ b/proof-of-work/app/main/admin/users/actions.ts @@ -0,0 +1,119 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { getCurrentUser, hashPassword, deleteOtherSessions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +/** + * Admin user-management server actions. + * + * Every action does its own admin gate (defense in depth — the page that + * calls them also checks `user.isAdmin`, but actions are POSTable from + * anywhere). The "last admin" guard prevents demoting or deleting the + * final admin, which would lock the instance out of admin-only flows. + */ + +async function requireAdmin() { + const me = await getCurrentUser(); + if (!me) throw new Error('Not signed in'); + if (!me.isAdmin) throw new Error('Forbidden'); + return me; +} + +async function adminCount(): Promise { + return prisma.user.count({ where: { isAdmin: true } }); +} + +export async function setUserAdmin( + userId: string, + makeAdmin: boolean, +): Promise<{ success?: true; error?: string }> { + try { + await requireAdmin(); + const target = await prisma.user.findUnique({ where: { id: userId } }); + if (!target) return { error: 'User not found.' }; + + if (target.isAdmin === makeAdmin) { + return { success: true }; // no-op + } + + if (target.isAdmin && !makeAdmin && (await adminCount()) <= 1) { + return { + error: + 'Cannot demote the last admin. Promote another user to admin first.', + }; + } + + await prisma.user.update({ + where: { id: userId }, + data: { isAdmin: makeAdmin }, + }); + revalidatePath('/main/admin/users'); + return { success: true }; + } catch (err) { + console.error('setUserAdmin error:', err); + return { error: (err as Error).message ?? 'Failed.' }; + } +} + +export async function adminResetPassword( + userId: string, + newPassword: string, +): Promise<{ success?: true; error?: string; revoked?: number }> { + try { + await requireAdmin(); + if (newPassword.length < 8) { + return { error: 'Password must be at least 8 characters.' }; + } + const target = await prisma.user.findUnique({ where: { id: userId } }); + if (!target) return { error: 'User not found.' }; + + const hash = await hashPassword(newPassword); + await prisma.user.update({ + where: { id: userId }, + data: { passwordHash: hash }, + }); + + // Force the user to re-authenticate everywhere — admin-initiated + // reset implies the old credential is no longer trusted. + const revoked = await deleteOtherSessions(userId, null); + revalidatePath('/main/admin/users'); + return { success: true, revoked }; + } catch (err) { + console.error('adminResetPassword error:', err); + return { error: (err as Error).message ?? 'Failed.' }; + } +} + +export async function deleteUser( + userId: string, +): Promise<{ success?: true; error?: string }> { + try { + const me = await requireAdmin(); + if (userId === me.id) { + return { + error: + 'You cannot delete your own account from here. Ask another admin, or do it from your own settings page.', + }; + } + const target = await prisma.user.findUnique({ where: { id: userId } }); + if (!target) return { error: 'User not found.' }; + + if (target.isAdmin && (await adminCount()) <= 1) { + return { + error: + 'Cannot delete the last admin. Promote another user to admin first.', + }; + } + + // Cascading delete is configured in schema.prisma via onDelete: + // Cascade on every relation owned by User, so this removes their + // workouts, exercises, sessions, preferences, etc. in one shot. + await prisma.user.delete({ where: { id: userId } }); + revalidatePath('/main/admin/users'); + return { success: true }; + } catch (err) { + console.error('deleteUser error:', err); + return { error: (err as Error).message ?? 'Failed.' }; + } +} diff --git a/proof-of-work/app/main/admin/users/page.tsx b/proof-of-work/app/main/admin/users/page.tsx new file mode 100644 index 0000000..974185b --- /dev/null +++ b/proof-of-work/app/main/admin/users/page.tsx @@ -0,0 +1,52 @@ +import { redirect } from 'next/navigation'; +import { getCurrentUser } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import UsersTable from '@/components/admin/UsersTable'; + +export const dynamic = 'force-dynamic'; + +export default async function AdminUsersPage() { + const me = await getCurrentUser(); + if (!me) redirect('/auth/login'); + if (!me.isAdmin) redirect('/main/dashboard'); + + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + name: true, + isAdmin: true, + createdAt: true, + _count: { select: { sessions: true, workouts: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + + // Serialize Date -> string for the client component boundary. + const usersForClient = users.map((u) => ({ + id: u.id, + email: u.email, + name: u.name, + isAdmin: u.isAdmin, + createdAt: u.createdAt.toISOString(), + sessionCount: u._count.sessions, + workoutCount: u._count.workouts, + })); + + return ( +
+
+
+

Users

+

+ Admin only. Manage accounts on this Proof of Work instance. +

+
+
+ +
+ +
+
+ ); +} diff --git a/proof-of-work/app/main/settings/changePasswordAction.ts b/proof-of-work/app/main/settings/changePasswordAction.ts new file mode 100644 index 0000000..527c038 --- /dev/null +++ b/proof-of-work/app/main/settings/changePasswordAction.ts @@ -0,0 +1,60 @@ +'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.' }; + } +} diff --git a/proof-of-work/app/main/settings/page.tsx b/proof-of-work/app/main/settings/page.tsx index b34f0db..24ff02d 100644 --- a/proof-of-work/app/main/settings/page.tsx +++ b/proof-of-work/app/main/settings/page.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import { getCurrentUser } from "@/lib/auth"; import SettingsForm from "@/components/settings/SettingsForm"; +import ChangePasswordForm from "@/components/settings/ChangePasswordForm"; import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings"; import { getInstanceSettings } from "@/lib/instanceSettings"; @@ -26,6 +27,7 @@ export default async function SettingsPage() {
+ {user.isAdmin && instanceSettings && ( (null); + const [info, setInfo] = useState(null); + const [activeReset, setActiveReset] = useState(null); + + const flash = (msg: string, kind: 'error' | 'info') => { + setError(null); + setInfo(null); + if (kind === 'error') setError(msg); + else setInfo(msg); + setTimeout(() => { + setError(null); + setInfo(null); + }, 6000); + }; + + const onToggleAdmin = (u: UserRow) => { + startTransition(async () => { + const res = await setUserAdmin(u.id, !u.isAdmin); + if (res.error) flash(res.error, 'error'); + else flash(`${u.email} is now ${!u.isAdmin ? 'an admin' : 'a regular user'}.`, 'info'); + }); + }; + + const onDelete = (u: UserRow) => { + if ( + !window.confirm( + `Delete ${u.email}? This is permanent and removes their workouts, exercises, and sessions.`, + ) + ) { + return; + } + startTransition(async () => { + const res = await deleteUser(u.id); + if (res.error) flash(res.error, 'error'); + else flash(`Deleted ${u.email}.`, 'info'); + }); + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} + {info && ( +
+ {info} +
+ )} + +
+ + + + + + + + + + + + {users.map((u) => { + const isMe = u.id === currentUserId; + return ( + + + + + + + + ); + })} + +
UserJoinedWorkoutsRoleActions
+
{u.email}
+ {u.name &&
{u.name}
} + {isMe && ( + + you + + )} +
+ {new Date(u.createdAt).toLocaleDateString()} + + {u.workoutCount} + + + {u.isAdmin ? 'Admin' : 'User'} + + +
+ + + {!isMe && ( + + )} +
+ {activeReset === u.id && ( + { + flash(msg, ok ? 'info' : 'error'); + if (ok) setActiveReset(null); + }} + /> + )} +
+
+
+ ); +} + +function ResetPasswordRow({ + userId, + email, + onDone, +}: { + userId: string; + email: string; + onDone: (msg: string, ok: boolean) => void; +}) { + const [pwd, setPwd] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + const res = await adminResetPassword(userId, pwd); + setBusy(false); + if (res.error) { + onDone(res.error, false); + } else { + onDone( + `Password for ${email} reset.${ + (res.revoked ?? 0) > 0 + ? ` Revoked ${res.revoked} session${res.revoked === 1 ? '' : 's'}.` + : '' + }`, + true, + ); + setPwd(''); + } + }; + + return ( +
+ setPwd(e.target.value)} + placeholder="New password (8+ chars)" + minLength={8} + required + autoFocus + className="text-xs px-2 py-1 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500" + /> + +
+ ); +} diff --git a/proof-of-work/components/settings/AdminInstanceSettings.tsx b/proof-of-work/components/settings/AdminInstanceSettings.tsx index 9dba6d2..f70da3a 100644 --- a/proof-of-work/components/settings/AdminInstanceSettings.tsx +++ b/proof-of-work/components/settings/AdminInstanceSettings.tsx @@ -78,6 +78,15 @@ export default function AdminInstanceSettings({ {error}
)} + +
+ + Manage users → + +
); } diff --git a/proof-of-work/components/settings/ChangePasswordForm.tsx b/proof-of-work/components/settings/ChangePasswordForm.tsx new file mode 100644 index 0000000..b278626 --- /dev/null +++ b/proof-of-work/components/settings/ChangePasswordForm.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useState } from 'react'; +import { changePasswordAction } from '@/app/main/settings/changePasswordAction'; + +export default function ChangePasswordForm() { + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [confirm, setConfirm] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [done, setDone] = useState<{ revoked: number } | null>(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setDone(null); + setSaving(true); + try { + const res = await changePasswordAction(current, next, confirm); + if (res.error) { + setError(res.error); + setSaving(false); + return; + } + setDone({ revoked: res.revoked ?? 0 }); + setCurrent(''); + setNext(''); + setConfirm(''); + } catch (err) { + setError('Network error.'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

+ Change password +

+

+ We'll log you out of every other browser and device. +

+
+ +
+ + + + + {error && ( +
+ {error} +
+ )} + {done && ( +
+ Password updated. + {done.revoked > 0 + ? ` Logged out of ${done.revoked} other session${done.revoked === 1 ? '' : 's'}.` + : ''} +
+ )} + + + +
+ ); +} + +function Field({ + id, + label, + placeholder, + value, + onChange, + minLength, + disabled, +}: { + id: string; + label: string; + placeholder?: string; + value: string; + onChange: (v: string) => void; + minLength?: number; + disabled?: boolean; +}) { + return ( +
+ + onChange(e.target.value)} + required + minLength={minLength} + disabled={disabled} + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white focus:border-white transition-all disabled:opacity-50" + /> +
+ ); +} diff --git a/proof-of-work/lib/rateLimit.ts b/proof-of-work/lib/rateLimit.ts new file mode 100644 index 0000000..2667019 --- /dev/null +++ b/proof-of-work/lib/rateLimit.ts @@ -0,0 +1,62 @@ +/** + * Tiny in-process sliding-window rate limiter. + * + * Per Node process — fine for a single Next.js standalone server, which is + * the only deployment shape we ship. If the app ever runs behind multiple + * replicas, swap the Map for a shared Redis-backed bucket; the public API + * here stays the same. + * + * No deps: a Map of `key -> sorted timestamps[]`. Each call drops + * timestamps older than the window, then either records the new request + * and returns ok, or returns blocked with retry-after seconds. + * + * Memory: O(active-keys * limit). At a 5-per-15min ceiling and even + * thousands of distinct IPs, this is trivially small. + */ + +interface Window { + /** Max events allowed per `windowMs` per `key`. */ + limit: number; + /** Window length in ms. */ + windowMs: number; +} + +const buckets = new Map(); + +export function rateLimit( + key: string, + { limit, windowMs }: Window, +): { ok: true } | { ok: false; retryAfterSec: number } { + const now = Date.now(); + const cutoff = now - windowMs; + const hits = (buckets.get(key) ?? []).filter((t) => t > cutoff); + + if (hits.length >= limit) { + const oldest = hits[0]; + const retryAfterSec = Math.max(1, Math.ceil((oldest + windowMs - now) / 1000)); + buckets.set(key, hits); + return { ok: false, retryAfterSec }; + } + + hits.push(now); + buckets.set(key, hits); + return { ok: true }; +} + +/** + * Best-effort client IP extraction. In a StartOS deployment the Next.js + * server sits behind a single proxy hop, so the leftmost + * `x-forwarded-for` entry is the originating client. If headers are + * absent (direct access in dev), fall back to the literal "unknown" key + * so the limiter still applies as a global rate cap. + */ +export function clientIpFromHeaders(headers: Headers): string { + const xff = headers.get('x-forwarded-for'); + if (xff) { + const first = xff.split(',')[0]?.trim(); + if (first) return first; + } + const real = headers.get('x-real-ip'); + if (real) return real; + return 'unknown'; +}