feat: full feature buildout — streaming, i18n, mastery map, admin, jobs
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>
This commit is contained in:
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -13,6 +14,7 @@ interface Props {
|
||||
}
|
||||
|
||||
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') ?? '/';
|
||||
@@ -33,7 +35,7 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
|
||||
});
|
||||
setLoading(false);
|
||||
if (res?.error) {
|
||||
setError('Invalid email or password.');
|
||||
setError(t('error'));
|
||||
} else {
|
||||
await migrateThenRedirect(callbackUrl, router);
|
||||
}
|
||||
@@ -41,12 +43,12 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
|
||||
|
||||
return (
|
||||
<main style={styles.page}>
|
||||
<h1 style={styles.heading}>Sign in</h1>
|
||||
<h1 style={styles.heading}>{t('title')}</h1>
|
||||
{resetSuccess && (
|
||||
<p role="status" style={styles.success}>Password updated. Sign in with your new password.</p>
|
||||
<p role="status" style={styles.success}>{t('resetSuccess')}</p>
|
||||
)}
|
||||
<form onSubmit={handleCredentials} style={styles.form} noValidate>
|
||||
<label style={styles.label} htmlFor="email">Email</label>
|
||||
<label style={styles.label} htmlFor="email">{t('email')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
@@ -56,7 +58,7 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
|
||||
autoComplete="email"
|
||||
style={styles.input}
|
||||
/>
|
||||
<label style={styles.label} htmlFor="password">Password</label>
|
||||
<label style={styles.label} htmlFor="password">{t('password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
@@ -68,34 +70,34 @@ export default function LoginClient({ hasOidc, oidcLabel, hasGoogle, hasGitHub }
|
||||
/>
|
||||
{error && <p role="alert" style={styles.error}>{error}</p>}
|
||||
<button type="submit" disabled={loading} style={styles.btn}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
{loading ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
<Link href="/auth/forgot-password" style={styles.forgotLink}>Forgot password?</Link>
|
||||
<Link href="/auth/forgot-password" style={styles.forgotLink}>{t('forgotPassword')}</Link>
|
||||
</form>
|
||||
|
||||
{(hasGoogle || hasGitHub || hasOidc) && <div style={styles.divider}><span>or</span></div>}
|
||||
{(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}>
|
||||
Continue with Google
|
||||
{t('continueWithGoogle')}
|
||||
</button>
|
||||
)}
|
||||
{hasGitHub && (
|
||||
<button type="button" onClick={() => signIn('github', { callbackUrl })} style={styles.oauthBtn}>
|
||||
Continue with GitHub
|
||||
{t('continueWithGitHub')}
|
||||
</button>
|
||||
)}
|
||||
{hasOidc && (
|
||||
<button type="button" onClick={() => signIn('authentik', { callbackUrl })} style={styles.oauthBtn}>
|
||||
Continue with {oidcLabel}
|
||||
{t('continueWith', { provider: oidcLabel })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p style={styles.footer}>
|
||||
No account?{' '}
|
||||
<Link href="/auth/register" style={styles.link}>Register</Link>
|
||||
{t('noAccount')}{' '}
|
||||
<Link href="/auth/register" style={styles.link}>{t('register')}</Link>
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
@@ -115,6 +117,17 @@ async function migrateThenRedirect(callbackUrl: string, router: ReturnType<typeo
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -169,7 +182,7 @@ const styles = {
|
||||
marginTop: 'var(--space-4)',
|
||||
padding: 'var(--space-3) var(--space-6)',
|
||||
background: 'var(--color-accent)',
|
||||
color: '#fff',
|
||||
color: 'var(--color-on-accent)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'var(--font-display)',
|
||||
@@ -225,8 +238,8 @@ const styles = {
|
||||
width: '100%',
|
||||
maxWidth: '26rem',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
background: 'var(--color-mastered-bg, #eaf5ea)',
|
||||
color: 'var(--color-mastered, #1a6e2e)',
|
||||
background: 'var(--color-mastered-subtle)',
|
||||
color: 'var(--color-mastered)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.875rem',
|
||||
margin: 0,
|
||||
|
||||
Reference in New Issue
Block a user