5bf6013460
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>
99 lines
4.3 KiB
TypeScript
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;
|