Self-serve password change, admin user management, login/signup rate limit
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.
This commit is contained in:
@@ -1,12 +1,24 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies, headers } from 'next/headers';
|
||||||
import { verifyPassword, createSession } from '@/lib/auth';
|
import { verifyPassword, createSession } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { rateLimit, clientIpFromHeaders } from '@/lib/rateLimit';
|
||||||
|
|
||||||
export async function loginAction(email: string, password: string) {
|
export async function loginAction(email: string, password: string) {
|
||||||
try {
|
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
|
// Look up user by email
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { cookies } from 'next/headers';
|
import { cookies, headers } from 'next/headers';
|
||||||
import { hashPassword, createSession } from '@/lib/auth';
|
import { hashPassword, createSession } from '@/lib/auth';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { getInstanceSettings } from '@/lib/instanceSettings';
|
import { getInstanceSettings } from '@/lib/instanceSettings';
|
||||||
import { ensureLibraryForUser } from '@/lib/library';
|
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,}$/;
|
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,
|
name?: string,
|
||||||
) {
|
) {
|
||||||
try {
|
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();
|
const settings = await getInstanceSettings();
|
||||||
if (!settings.signupsOpen) {
|
if (!settings.signupsOpen) {
|
||||||
return { error: 'New sign-ups are not enabled on this instance.' };
|
return { error: 'New sign-ups are not enabled on this instance.' };
|
||||||
|
|||||||
@@ -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<number> {
|
||||||
|
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.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[#0A0A0A]">
|
||||||
|
<div className="border-b border-zinc-800 px-4 py-4 sm:px-6">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Users</h1>
|
||||||
|
<p className="text-zinc-500 text-sm mt-1">
|
||||||
|
Admin only. Manage accounts on this Proof of Work instance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-6 sm:px-6">
|
||||||
|
<UsersTable users={usersForClient} currentUserId={me.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import SettingsForm from "@/components/settings/SettingsForm";
|
import SettingsForm from "@/components/settings/SettingsForm";
|
||||||
|
import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
|
||||||
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
|
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
|
||||||
import { getInstanceSettings } from "@/lib/instanceSettings";
|
import { getInstanceSettings } from "@/lib/instanceSettings";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export default async function SettingsPage() {
|
|||||||
|
|
||||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 space-y-8">
|
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 space-y-8">
|
||||||
<SettingsForm user={user} />
|
<SettingsForm user={user} />
|
||||||
|
<ChangePasswordForm />
|
||||||
{user.isAdmin && instanceSettings && (
|
{user.isAdmin && instanceSettings && (
|
||||||
<AdminInstanceSettings
|
<AdminInstanceSettings
|
||||||
initialSignupsOpen={instanceSettings.signupsOpen}
|
initialSignupsOpen={instanceSettings.signupsOpen}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import {
|
||||||
|
setUserAdmin,
|
||||||
|
adminResetPassword,
|
||||||
|
deleteUser,
|
||||||
|
} from '@/app/main/admin/users/actions';
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
isAdmin: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
sessionCount: number;
|
||||||
|
workoutCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UsersTable({
|
||||||
|
users,
|
||||||
|
currentUserId,
|
||||||
|
}: {
|
||||||
|
users: UserRow[];
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
|
const [activeReset, setActiveReset] = useState<string | null>(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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{info && (
|
||||||
|
<div className="rounded bg-emerald-900/40 px-3 py-2 border border-emerald-800 text-xs text-emerald-300">
|
||||||
|
{info}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-zinc-900 rounded border border-zinc-800 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-zinc-800/50 text-xs uppercase tracking-wider text-zinc-400">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">User</th>
|
||||||
|
<th className="text-left px-4 py-3 hidden sm:table-cell">Joined</th>
|
||||||
|
<th className="text-left px-4 py-3 hidden sm:table-cell">Workouts</th>
|
||||||
|
<th className="text-left px-4 py-3">Role</th>
|
||||||
|
<th className="text-right px-4 py-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => {
|
||||||
|
const isMe = u.id === currentUserId;
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-t border-zinc-800">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-white font-medium">{u.email}</div>
|
||||||
|
{u.name && <div className="text-xs text-zinc-500">{u.name}</div>}
|
||||||
|
{isMe && (
|
||||||
|
<span className="inline-block mt-1 text-[10px] uppercase tracking-wider bg-zinc-800 text-zinc-300 px-1.5 py-0.5 rounded">
|
||||||
|
you
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-zinc-500 hidden sm:table-cell">
|
||||||
|
{new Date(u.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-zinc-400 hidden sm:table-cell">
|
||||||
|
{u.workoutCount}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`text-xs uppercase tracking-wider ${
|
||||||
|
u.isAdmin ? 'text-white' : 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.isAdmin ? 'Admin' : 'User'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex justify-end gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleAdmin(u)}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{u.isAdmin ? 'Demote' : 'Promote'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setActiveReset(activeReset === u.id ? null : u.id)
|
||||||
|
}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
{!isMe && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(u)}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-red-900 text-red-400 hover:bg-red-900/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{activeReset === u.id && (
|
||||||
|
<ResetPasswordRow
|
||||||
|
userId={u.id}
|
||||||
|
email={u.email}
|
||||||
|
onDone={(msg, ok) => {
|
||||||
|
flash(msg, ok ? 'info' : 'error');
|
||||||
|
if (ok) setActiveReset(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<form onSubmit={submit} className="mt-3 flex gap-2 justify-end">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pwd}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="text-xs px-2 py-1 rounded bg-white text-black font-bold uppercase tracking-wider disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? '...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -78,6 +78,15 @@ export default function AdminInstanceSettings({
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-zinc-800">
|
||||||
|
<a
|
||||||
|
href="/main/admin/users"
|
||||||
|
className="inline-block text-xs uppercase tracking-wider text-white underline hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
Manage users →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<section className="bg-zinc-900 rounded border border-zinc-800 p-6 space-y-4">
|
||||||
|
<header>
|
||||||
|
<h2 className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||||
|
Change password
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
We'll log you out of every other browser and device.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Field
|
||||||
|
id="current"
|
||||||
|
label="Current password"
|
||||||
|
value={current}
|
||||||
|
onChange={setCurrent}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
id="next"
|
||||||
|
label="New password"
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
value={next}
|
||||||
|
onChange={setNext}
|
||||||
|
minLength={8}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
id="confirm"
|
||||||
|
label="Confirm new password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={setConfirm}
|
||||||
|
minLength={8}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{done && (
|
||||||
|
<div className="rounded bg-emerald-900/40 px-3 py-2 border border-emerald-800 text-xs text-emerald-300">
|
||||||
|
Password updated.
|
||||||
|
{done.revoked > 0
|
||||||
|
? ` Logged out of ${done.revoked} other session${done.revoked === 1 ? '' : 's'}.`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded bg-white text-black font-bold text-xs uppercase tracking-wider transition-all hover:bg-gray-100 disabled:bg-zinc-700 disabled:text-zinc-500 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? 'Updating...' : 'Update password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className="text-xs font-semibold text-white uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
placeholder={placeholder ?? '••••••••'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, number[]>();
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user