feat(frontend): admin users page (invite, role, activate, send-reset)
This commit is contained in:
121
packages/frontend/src/pages/AdminUsers.tsx
Normal file
121
packages/frontend/src/pages/AdminUsers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user