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>
248 lines
6.9 KiB
TypeScript
248 lines
6.9 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { signIn } from 'next-auth/react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { useTranslations } from 'next-intl';
|
|
|
|
interface Props {
|
|
hasOidc: boolean;
|
|
oidcLabel: string;
|
|
hasGoogle: boolean;
|
|
hasGitHub: boolean;
|
|
}
|
|
|
|
export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }: Props) {
|
|
const t = useTranslations('auth.login');
|
|
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(t('error'));
|
|
} else {
|
|
await migrateThenRedirect(callbackUrl, router);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main style={styles.page}>
|
|
<h1 style={styles.heading}>{t('title')}</h1>
|
|
{resetSuccess && (
|
|
<p role="status" style={styles.success}>{t('resetSuccess')}</p>
|
|
)}
|
|
<form onSubmit={handleCredentials} style={styles.form} noValidate>
|
|
<label style={styles.label} htmlFor="email">{t('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">{t('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 ? t('submitting') : t('submit')}
|
|
</button>
|
|
<Link href="/auth/forgot-password" style={styles.forgotLink}>{t('forgotPassword')}</Link>
|
|
</form>
|
|
|
|
{(hasGoogle || hasGitHub || hasOidc) && <div style={styles.divider}><span>{t('or')}</span></div>}
|
|
|
|
<div style={styles.oauthGroup}>
|
|
{hasGoogle && (
|
|
<button type="button" onClick={() => signIn('google', { callbackUrl })} style={styles.oauthBtn}>
|
|
{t('continueWithGoogle')}
|
|
</button>
|
|
)}
|
|
{hasGitHub && (
|
|
<button type="button" onClick={() => signIn('github', { callbackUrl })} style={styles.oauthBtn}>
|
|
{t('continueWithGitHub')}
|
|
</button>
|
|
)}
|
|
{hasOidc && (
|
|
<button type="button" onClick={() => signIn('authentik', { callbackUrl })} style={styles.oauthBtn}>
|
|
{t('continueWith', { provider: oidcLabel })}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<p style={styles.footer}>
|
|
{t('noAccount')}{' '}
|
|
<Link href="/auth/register" style={styles.link}>{t('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
|
|
}
|
|
}
|
|
// Sync saved locale preference to cookie so UI reflects user's language
|
|
try {
|
|
const localeRes = await fetch('/api/user/locale');
|
|
if (localeRes.ok) {
|
|
const { locale } = (await localeRes.json()) as { locale: string };
|
|
document.cookie = `NEXT_LOCALE=${locale};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
|
|
}
|
|
} catch {
|
|
// non-blocking
|
|
}
|
|
router.refresh();
|
|
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: 'var(--color-on-accent)',
|
|
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-subtle)',
|
|
color: 'var(--color-mastered)',
|
|
borderRadius: '4px',
|
|
fontSize: '0.875rem',
|
|
margin: 0,
|
|
},
|
|
} as const;
|