Files
Curio/src/app/admin/users/users-client.tsx
T
arnaudne 5bf6013460 feat: full feature buildout — streaming, i18n, mastery map, admin, jobs
Progressive lesson streaming via onSegment callback (fixes SSE for non-English
users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers,
Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval
runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema,
mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions),
image queries, source citations, view transitions, reading animations, i18n
(next-intl), PDF export, surprise endpoint, and 402 passing unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:08:14 +02:00

99 lines
4.3 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
type UserRow = { id: string; name: string | null; email: string | null; role: 'learner' | 'admin' | 'suspended'; createdAt: Date };
export default function AdminUsersClient({ users, selfId }: { users: UserRow[]; selfId: string }) {
const router = useRouter();
const [busy, setBusy] = useState<string | null>(null);
const action = async (id: string, body: object) => {
setBusy(id);
await fetch(`/api/admin/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
setBusy(null);
router.refresh();
};
const del = async (id: string) => {
if (!confirm('Delete this user permanently?')) return;
setBusy(id);
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
setBusy(null);
router.refresh();
};
return (
<div>
<h1 style={s.heading}>Users</h1>
<div style={{ overflowX: 'auto' }}>
<table style={s.table}>
<thead>
<tr>
{['Email', 'Name', 'Role', 'Joined', 'Actions'].map((h) => (
<th key={h} style={s.th}>{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td style={s.td}>{u.email ?? '—'}</td>
<td style={s.td}>{u.name ?? '—'}</td>
<td style={s.td}>{u.role}</td>
<td style={s.td}>{new Date(u.createdAt).toLocaleDateString()}</td>
<td style={{ ...s.td, display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap' as const }}>
{u.id === selfId ? (
<span style={s.selfNote}>You</span>
) : (
<>
{u.role !== 'admin' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'admin' })} style={s.btn}>
Make admin
</button>
)}
{u.role === 'admin' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'learner' })} style={s.btn}>
Make learner
</button>
)}
{u.role !== 'suspended' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'suspended' })} style={s.btn}>
Suspend
</button>
)}
{u.role === 'suspended' && (
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'learner' })} style={s.btn}>
Unsuspend
</button>
)}
<button disabled={busy === u.id} onClick={() => del(u.id)} style={s.dangerBtn}>
Delete
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
const s = {
heading: { fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 500, color: 'var(--color-ink)', marginBottom: 'var(--space-6)' },
table: { width: '100%', borderCollapse: 'collapse' as const, fontFamily: 'var(--font-display)', fontSize: '0.875rem' },
th: { textAlign: 'left' as const, padding: 'var(--space-2) var(--space-3)', borderBottom: '2px solid var(--color-border)', color: 'var(--color-ink-faint)', fontWeight: 600 },
td: { padding: 'var(--space-2) var(--space-3)', borderBottom: '1px solid var(--color-border-subtle)', color: 'var(--color-ink)' },
btn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-accent-subtle)', color: 'var(--color-accent)', border: '1px solid var(--color-accent)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' },
dangerBtn: { padding: 'var(--space-1) var(--space-3)', background: 'var(--color-misconception-subtle)', color: 'var(--color-misconception)', border: '1px solid var(--color-misconception)', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8125rem' },
selfNote: { color: 'var(--color-ink-faint)', fontSize: '0.8125rem' },
} as const;