feat(frontend): admin users page (invite, role, activate, send-reset)

This commit is contained in:
2026-05-20 23:11:53 +02:00
parent 88ba0a790c
commit 117cd52e3e

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { adminUsersApi, type ListUsersResponse } from '../api/admin-users.js';
import type { User } from '@flashcard/shared';
import { ApiClientError } from '../api/client.js';
export function AdminUsersPage() {
const [data, setData] = useState<ListUsersResponse>({ rows: [], total: 0 });
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<'user' | 'sysadmin'>('user');
const [inviteMsg, setInviteMsg] = useState<string | null>(null);
async function refresh() {
setBusy(true);
try { setData(await adminUsersApi.list({ q: q.trim() || undefined })); }
finally { setBusy(false); }
}
useEffect(() => { refresh(); }, []);
async function invite(e: React.FormEvent) {
e.preventDefault();
setInviteMsg(null);
try {
await adminUsersApi.invite({ email: inviteEmail, role: inviteRole });
setInviteMsg('Uitnodiging verstuurd.');
setInviteEmail('');
await refresh();
} catch (err) {
setInviteMsg(err instanceof ApiClientError ? err.message : 'Uitnodigen mislukt.');
}
}
async function setActive(u: User, isActive: boolean) {
try { await adminUsersApi.update(u.id, { isActive }); await refresh(); }
catch (err) { alert(err instanceof ApiClientError ? err.message : 'Bijwerken mislukt.'); }
}
async function setRole(u: User, role: 'user' | 'sysadmin') {
try { await adminUsersApi.update(u.id, { role }); await refresh(); }
catch (err) { alert(err instanceof ApiClientError ? err.message : 'Bijwerken mislukt.'); }
}
async function sendReset(u: User) {
if (!confirm(`Reset/uitnodigingsmail sturen naar ${u.email}?`)) return;
try { await adminUsersApi.sendReset(u.id); alert('Mail verstuurd.'); }
catch (err) { alert(err instanceof ApiClientError ? err.message : 'Versturen mislukt.'); }
}
return (
<div className="space-y-6">
<header>
<h1 className="font-display text-3xl font-bold">👑 Gebruikersbeheer</h1>
<p className="text-sm text-slate-500">{data.total} gebruiker(s) totaal</p>
</header>
<form onSubmit={invite} className="surface flex flex-col gap-2 p-4 sm:flex-row sm:items-end">
<label className="flex-1 text-sm">
<span className="mb-1 block font-medium">Uitnodigen via e-mail</span>
<input type="email" required className="input-field" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} placeholder="naam@voorbeeld.com" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium">Rol</span>
<select className="input-field" value={inviteRole} onChange={(e) => setInviteRole(e.target.value as 'user' | 'sysadmin')}>
<option value="user">Gebruiker</option>
<option value="sysadmin">Beheerder</option>
</select>
</label>
<button className="btn-primary shrink-0">Uitnodiging sturen</button>
</form>
{inviteMsg && <p className="text-sm">{inviteMsg}</p>}
<div className="surface p-4">
<div className="mb-3 flex gap-2">
<input className="input-field flex-1" placeholder="Zoek op e-mail of naam…" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && refresh()} />
<button className="btn-ghost" onClick={refresh} disabled={busy}>Zoek</button>
</div>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-slate-500">
<th className="px-2 py-2">Email</th>
<th className="px-2 py-2">Naam</th>
<th className="px-2 py-2">Rol</th>
<th className="px-2 py-2">Status</th>
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-brand-100/60 dark:divide-slate-800">
{data.rows.map((u) => (
<tr key={u.id} className="hover:bg-brand-50/40 dark:hover:bg-slate-800/40">
<td className="px-2 py-2">{u.email}</td>
<td className="px-2 py-2">{u.displayName}</td>
<td className="px-2 py-2">
<select className="rounded-lg border border-brand-100 bg-white px-2 py-1 text-xs dark:border-slate-800 dark:bg-slate-900" value={u.role} onChange={(e) => setRole(u, e.target.value as 'user' | 'sysadmin')}>
<option value="user">user</option>
<option value="sysadmin">sysadmin</option>
</select>
</td>
<td className="px-2 py-2">
{u.isActive ? (
<span className="rounded-full bg-success-50 px-2 py-0.5 text-xs font-semibold text-success-700 dark:bg-success-700/20 dark:text-success-400">actief</span>
) : (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-400">uit</span>
)}
{!u.emailVerifiedAt && (
<span className="ml-1 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">onbevestigd</span>
)}
</td>
<td className="px-2 py-2 text-right">
<button className="rounded-lg px-2 py-1 text-xs text-brand-700 hover:bg-brand-50 dark:text-brand-200 dark:hover:bg-brand-900/40" onClick={() => sendReset(u)}>reset-mail</button>
<button className="rounded-lg px-2 py-1 text-xs text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" onClick={() => setActive(u, !u.isActive)}>{u.isActive ? 'deactiveer' : 'activeer'}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}