// Admin Login Gate — same visual language as CodeGate but amber accent. // Handles three states: // 1. login — email + password (+ optional TOTP) form // 2. enroll-show — show TOTP QR code after first login attempt // 3. enroll-done — confirm TOTP enrollment then back to login const AdminGate = ({ onSuccess, onBack }) => { const [step, setStep] = React.useState('login'); // login | enroll-show | enroll-done const [email, setEmail] = React.useState(''); const [password, setPassword] = React.useState(''); const [totp, setTotp] = React.useState(''); const [error, setError] = React.useState(''); const [submitting, setSubmitting] = React.useState(false); const [rateLimited, setRateLimited] = React.useState(false); const [qrData, setQrData] = React.useState(null); // { secret, keyUri, qrDataUrl } const emailRef = React.useRef(null); React.useEffect(() => { emailRef.current?.focus(); }, []); const submit = async () => { if (submitting || rateLimited) return; setError(''); if (!email.includes('@') || password.length < 8) { setError('Enter a valid email and password (min 8 chars).'); return; } setSubmitting(true); try { const res = await tmApi.admin.login(email.trim().toLowerCase(), password, totp || undefined); // Success onSuccess({ email: res.admin?.email || email, role: res.admin?.role || 'super' }); } catch (e) { const code = e.code || 'http_error'; if (code === 'rate_limited') { setRateLimited(true); setError('Too many login attempts. Wait 15 minutes and try again.'); setTimeout(() => setRateLimited(false), 15 * 60 * 1000); } else if (code === 'invalid_credentials') { // Single failure path — backend collapses all rejections (wrong // password, wrong TOTP, not enrolled) into this one code so the // response can't be used as a password-cracked oracle. setError('Invalid email or password.'); setTotp(''); } else { setError(e.message || 'Login failed.'); } } finally { setSubmitting(false); } }; const confirmEnroll = async () => { if (submitting) return; if (!/^\d{6}$/.test(totp)) { setError('TOTP must be exactly 6 digits.'); return; } setSubmitting(true); setError(''); try { await tmApi.admin.confirm2fa(email.trim().toLowerCase(), password, totp); // Now log in for real const res = await tmApi.admin.login(email.trim().toLowerCase(), password, totp); onSuccess({ email: res.admin?.email || email, role: res.admin?.role || 'super' }); } catch (e) { setError(e.message || 'Enrollment failed.'); } finally { setSubmitting(false); } }; // Explicit first-time enrollment — invoked manually via the "Set up 2FA" // link. The login endpoint no longer reveals "totp_enrollment_required", // so admins must opt into enrollment here on their first sign-in. const startEnroll = async () => { if (!email.includes('@') || password.length < 8) { setError('Enter your email + password first, then click Set up 2FA.'); return; } setError(''); setSubmitting(true); try { const start = await tmApi.admin.start2fa(email.trim().toLowerCase(), password); setQrData(start); setStep('enroll-show'); setTotp(''); } catch (e) { // Backend collapses all rejection paths (wrong creds, already enrolled) // into invalid_credentials. If you're already enrolled, just use the // regular Sign in form — don't click Set up 2FA. const c = e.code || ''; if (c === 'invalid_credentials') { setError('Invalid email or password (or already enrolled — use Sign in instead).'); } else if (c === 'rate_limited') { setError('Too many attempts. Wait 15 minutes.'); } else { setError(e.message || 'Could not start 2FA enrollment.'); } } finally { setSubmitting(false); } }; return (
Email + password + 6-digit TOTP code from your authenticator app.
First login — scan with Google Authenticator / Authy / 1Password,
then enter the 6-digit code below.