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,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;
|
||||
Reference in New Issue
Block a user