Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
|
||||
// Mounts at the root layout. Checks Supabase's AAL (Authenticator Assurance
|
||||
// Level) after every auth state change. If the user has MFA enrolled but
|
||||
// their session is still at aal1, we block the UI with a code challenge
|
||||
// until they reach aal2. Until verified, they can't see paid features.
|
||||
|
||||
type ChallengeState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'needed'; factorId: string }
|
||||
| { status: 'verifying'; factorId: string; challengeId: string };
|
||||
|
||||
export default function MFAChallenge() {
|
||||
const { user, session } = useAuth();
|
||||
const [state, setState] = useState<ChallengeState>({ status: 'idle' });
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const evaluate = useCallback(async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase || !user) {
|
||||
setState({ status: 'idle' });
|
||||
return;
|
||||
}
|
||||
const aal = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
if (aal.error) return;
|
||||
const { currentLevel, nextLevel } = aal.data;
|
||||
if (currentLevel === 'aal1' && nextLevel === 'aal2') {
|
||||
const { data } = await supabase.auth.mfa.listFactors();
|
||||
const factor = (data?.totp ?? []).find((f) => f.status === 'verified');
|
||||
if (factor) {
|
||||
setState({ status: 'needed', factorId: factor.id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState({ status: 'idle' });
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
void evaluate();
|
||||
}, [evaluate, session?.access_token]);
|
||||
|
||||
const issueChallenge = async () => {
|
||||
if (state.status !== 'needed') return;
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
const { data, error: cErr } = await supabase.auth.mfa.challenge({ factorId: state.factorId });
|
||||
if (cErr || !data) {
|
||||
setError(cErr?.message ?? 'Could not start MFA challenge.');
|
||||
return;
|
||||
}
|
||||
setState({ status: 'verifying', factorId: state.factorId, challengeId: data.id });
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (state.status !== 'verifying') return;
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await supabase.auth.mfa.verify({
|
||||
factorId: state.factorId,
|
||||
challengeId: state.challengeId,
|
||||
code,
|
||||
});
|
||||
if (res.error) {
|
||||
setError(res.error.message);
|
||||
return;
|
||||
}
|
||||
setCode('');
|
||||
setState({ status: 'idle' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (state.status === 'idle') return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Two-factor authentication required"
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/85 p-4"
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-lg border p-5"
|
||||
style={{ background: 'var(--bg-surface)', borderColor: 'var(--border-light)' }}
|
||||
>
|
||||
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Two-factor required
|
||||
</h2>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Enter the 6-digit code from your authenticator app.
|
||||
</p>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
className="mt-4 w-full rounded border px-3 py-2 text-center text-lg tracking-widest"
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
placeholder="123456"
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-xs" style={{ color: 'var(--grade-d)' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex gap-2">
|
||||
{state.status === 'needed' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={issueChallenge}
|
||||
className="w-full rounded px-4 py-2 text-sm font-semibold"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={code.length !== 6 || submitting}
|
||||
className="w-full rounded px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
{submitting ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user