feat: initial commit — Curio mastery tutor
Full Next.js App Router application with: - AI-graded lesson checkpoints (BKT/Elo mastery tracking) - Auth.js v5: credentials, Google, GitHub, generic OIDC - Anonymous-session-first with migrate-on-signin - Admin panel: users, blueprints, reports, site settings - Password reset + email verification (nodemailer/SMTP) - Site config: require_auth + signups_enabled flags - server-only guards on all DB/generation/verification modules - PostgreSQL 16 + pgvector, Redis cache, Drizzle ORM - 271 unit tests (Vitest), golden-eval harness, Playwright e2e stubs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
'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 }: { users: UserRow[] }) {
|
||||
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.role !== 'admin' && (
|
||||
<button disabled={busy === u.id} onClick={() => action(u.id, { role: 'admin' })} style={s.btn}>
|
||||
Make admin
|
||||
</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' },
|
||||
} as const;
|
||||
Reference in New Issue
Block a user