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
+234
View File
@@ -0,0 +1,234 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
interface Props {
hasOidc: boolean;
oidcLabel: string;
hasGoogle: boolean;
hasGitHub: boolean;
}
export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }: Props) {
const router = useRouter();
const params = useSearchParams();
const callbackUrl = params.get('callbackUrl') ?? '/';
const resetSuccess = params.get('reset') === '1';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleCredentials = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const res = await signIn('credentials', {
email,
password,
redirect: false,
});
setLoading(false);
if (res?.error) {
setError('Invalid email or password.');
} else {
await migrateThenRedirect(callbackUrl, router);
}
};
return (
<main style={styles.page}>
<h1 style={styles.heading}>Sign in</h1>
{resetSuccess && (
<p role="status" style={styles.success}>Password updated. Sign in with your new password.</p>
)}
<form onSubmit={handleCredentials} style={styles.form} noValidate>
<label style={styles.label} htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
style={styles.input}
/>
<label style={styles.label} htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
style={styles.input}
/>
{error && <p role="alert" style={styles.error}>{error}</p>}
<button type="submit" disabled={loading} style={styles.btn}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
<Link href="/auth/forgot-password" style={styles.forgotLink}>Forgot password?</Link>
</form>
{(hasGoogle || hasGitHub || hasOidc) && <div style={styles.divider}><span>or</span></div>}
<div style={styles.oauthGroup}>
{hasGoogle && (
<button type="button" onClick={() => signIn('google', { callbackUrl })} style={styles.oauthBtn}>
Continue with Google
</button>
)}
{hasGitHub && (
<button type="button" onClick={() => signIn('github', { callbackUrl })} style={styles.oauthBtn}>
Continue with GitHub
</button>
)}
{hasOidc && (
<button type="button" onClick={() => signIn('authentik', { callbackUrl })} style={styles.oauthBtn}>
Continue with {oidcLabel}
</button>
)}
</div>
<p style={styles.footer}>
No account?{' '}
<Link href="/auth/register" style={styles.link}>Register</Link>
</p>
</main>
);
}
async function migrateThenRedirect(callbackUrl: string, router: ReturnType<typeof useRouter>) {
const anonId = sessionStorage.getItem('curio_uid');
if (anonId) {
try {
await fetch('/api/auth/migrate-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ anonUserId: anonId }),
});
sessionStorage.removeItem('curio_uid');
} catch {
// non-blocking
}
}
router.push(callbackUrl);
}
const styles = {
page: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
minHeight: '100svh',
padding: 'var(--space-8)',
paddingTop: 'var(--space-16)',
gap: 'var(--space-4)',
},
heading: {
fontFamily: 'var(--font-display)',
fontSize: '1.5rem',
fontWeight: 500,
color: 'var(--color-ink)',
marginBottom: 'var(--space-4)',
},
form: {
width: '100%',
maxWidth: '26rem',
display: 'flex',
flexDirection: 'column' as const,
gap: 'var(--space-2)',
},
label: {
fontFamily: 'var(--font-display)',
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-ink)',
marginTop: 'var(--space-3)',
},
input: {
width: '100%',
padding: 'var(--space-3) var(--space-4)',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: '4px',
fontFamily: 'var(--font-body)',
fontSize: '1rem',
color: 'var(--color-ink)',
outline: 'none',
},
error: {
color: 'var(--color-misconception)',
fontSize: '0.875rem',
margin: 0,
},
btn: {
marginTop: 'var(--space-4)',
padding: 'var(--space-3) var(--space-6)',
background: 'var(--color-accent)',
color: '#fff',
border: 'none',
borderRadius: '4px',
fontFamily: 'var(--font-display)',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
},
divider: {
width: '100%',
maxWidth: '26rem',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
color: 'var(--color-ink-faint)',
fontSize: '0.875rem',
marginTop: 'var(--space-2)',
},
oauthGroup: {
width: '100%',
maxWidth: '26rem',
display: 'flex',
flexDirection: 'column' as const,
gap: 'var(--space-2)',
},
oauthBtn: {
width: '100%',
padding: 'var(--space-3) var(--space-4)',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: '4px',
fontFamily: 'var(--font-display)',
fontSize: '0.9375rem',
color: 'var(--color-ink)',
cursor: 'pointer',
},
footer: {
fontSize: '0.875rem',
color: 'var(--color-ink-muted)',
marginTop: 'var(--space-4)',
},
link: {
color: 'var(--color-accent)',
textDecoration: 'none',
},
forgotLink: {
color: 'var(--color-ink-muted)',
fontSize: '0.8125rem',
textDecoration: 'none',
textAlign: 'right' as const,
marginTop: 'var(--space-1)',
},
success: {
width: '100%',
maxWidth: '26rem',
padding: 'var(--space-3) var(--space-4)',
background: 'var(--color-mastered-bg, #eaf5ea)',
color: 'var(--color-mastered, #1a6e2e)',
borderRadius: '4px',
fontSize: '0.875rem',
margin: 0,
},
} as const;