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:
2026-06-21 22:25:43 +02:00
commit 4e38b5a791
194 changed files with 30789 additions and 0 deletions
+86
View File
@@ -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;