Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getBrowserSupabase } from '@/lib/supabase';
|
||||
import ExplainModeToggle from '@/components/ExplainModeToggle';
|
||||
|
||||
// MFA settings page. Gated to paid tiers (analyst | desk) — free users get
|
||||
// redirected to /pricing. We don't *force* MFA, only strongly encourage it,
|
||||
// because we don't want to lock paying users out of an account they own.
|
||||
|
||||
type EnrollState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'enrolling'; factorId: string; qr: string; secret: string }
|
||||
| { status: 'enrolled' }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
export default function SecuritySettingsPage() {
|
||||
const router = useRouter();
|
||||
const { user, tier, loading: authLoading } = useAuth();
|
||||
const [hasMFA, setHasMFA] = useState<boolean | null>(null);
|
||||
const [enrollState, setEnrollState] = useState<EnrollState>({ status: 'idle' });
|
||||
const [code, setCode] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const refreshFactors = useCallback(async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
const { data, error } = await supabase.auth.mfa.listFactors();
|
||||
if (error) return;
|
||||
const verified = (data?.totp ?? []).some((f) => f.status === 'verified');
|
||||
setHasMFA(verified);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
router.replace('/login?next=/settings/security');
|
||||
return;
|
||||
}
|
||||
if (tier === 'free') {
|
||||
router.replace('/upgrade/desk');
|
||||
return;
|
||||
}
|
||||
void refreshFactors();
|
||||
}, [authLoading, user, tier, router, refreshFactors]);
|
||||
|
||||
const startEnroll = async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.enroll({ factorType: 'totp', friendlyName: 'VYNDR' });
|
||||
if (error) {
|
||||
setEnrollState({ status: 'error', message: error.message });
|
||||
return;
|
||||
}
|
||||
setEnrollState({
|
||||
status: 'enrolling',
|
||||
factorId: data.id,
|
||||
qr: data.totp.qr_code,
|
||||
secret: data.totp.secret,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verify = async () => {
|
||||
if (enrollState.status !== 'enrolling') return;
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const challenge = await supabase.auth.mfa.challenge({ factorId: enrollState.factorId });
|
||||
if (challenge.error) {
|
||||
setEnrollState({ status: 'error', message: challenge.error.message });
|
||||
return;
|
||||
}
|
||||
const verifyRes = await supabase.auth.mfa.verify({
|
||||
factorId: enrollState.factorId,
|
||||
challengeId: challenge.data.id,
|
||||
code,
|
||||
});
|
||||
if (verifyRes.error) {
|
||||
setEnrollState({ status: 'error', message: verifyRes.error.message });
|
||||
return;
|
||||
}
|
||||
setEnrollState({ status: 'enrolled' });
|
||||
setHasMFA(true);
|
||||
setCode('');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
const supabase = getBrowserSupabase();
|
||||
if (!supabase) return;
|
||||
if (!window.confirm('Disable two-factor authentication? Your account will be less secure.')) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data } = await supabase.auth.mfa.listFactors();
|
||||
const factor = (data?.totp ?? []).find((f) => f.status === 'verified');
|
||||
if (!factor) return;
|
||||
const res = await supabase.auth.mfa.unenroll({ factorId: factor.id });
|
||||
if (res.error) {
|
||||
setEnrollState({ status: 'error', message: res.error.message });
|
||||
return;
|
||||
}
|
||||
setHasMFA(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || hasMFA === null) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-12">
|
||||
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
|
||||
Account security
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Two-factor authentication adds a one-time code on top of your password.
|
||||
</p>
|
||||
|
||||
<section
|
||||
className="mt-8 rounded-lg border p-5"
|
||||
style={{ background: 'var(--bg-surface)', borderColor: 'var(--border-light)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
Authenticator app
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{hasMFA ? 'Enabled — codes from your authenticator are required at sign-in.' : 'Not enabled.'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-2 py-1 text-xs font-semibold"
|
||||
style={{
|
||||
background: hasMFA ? 'var(--grade-a)' : 'var(--bg-elevated)',
|
||||
color: hasMFA ? 'var(--bg-0)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{hasMFA ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!hasMFA && enrollState.status === 'idle' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEnroll}
|
||||
disabled={submitting}
|
||||
className="mt-4 rounded px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
Set up
|
||||
</button>
|
||||
)}
|
||||
|
||||
{enrollState.status === 'enrolling' && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
Scan this code with Google Authenticator, Authy, or 1Password.
|
||||
</p>
|
||||
{/* QR is a data: URI emitted by Supabase — safe to embed directly. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={enrollState.qr} alt="MFA QR code" width={180} height={180} />
|
||||
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Can't scan? Enter this code manually: <code>{enrollState.secret}</code>
|
||||
</p>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="6-digit code"
|
||||
className="w-32 rounded border px-3 py-2 text-sm"
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
borderColor: 'var(--border-light)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={verify}
|
||||
disabled={code.length !== 6 || submitting}
|
||||
className="ml-2 rounded px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ background: 'var(--grade-a)', color: 'var(--bg-0)' }}
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enrollState.status === 'enrolled' && (
|
||||
<p className="mt-4 text-sm" style={{ color: 'var(--grade-a)' }}>
|
||||
MFA enabled. You'll be asked for a code the next time you sign in.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{enrollState.status === 'error' && (
|
||||
<p className="mt-4 text-sm" style={{ color: 'var(--grade-d)' }}>
|
||||
{enrollState.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasMFA && enrollState.status !== 'enrolling' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={disable}
|
||||
disabled={submitting}
|
||||
className="mt-4 rounded border px-4 py-2 text-sm font-semibold disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-light)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Disable MFA
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<ExplainModeToggle variant="full" />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user