2026-04-25 00:21:07 +02:00
|
|
|
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
2026-04-09 19:49:14 +02:00
|
|
|
import { Eye, EyeOff } from 'lucide-react';
|
2026-04-06 11:43:22 +02:00
|
|
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
2026-04-15 15:28:44 +02:00
|
|
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
2026-04-27 18:33:46 +02:00
|
|
|
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
|
2026-04-26 13:39:31 +02:00
|
|
|
import {
|
|
|
|
|
signIn, startRegistration, completeRegistration,
|
|
|
|
|
startForgotPassword, forgotPasswordVerifyAndReset,
|
|
|
|
|
verifyTotp, verifyBackupCode, submitMfa,
|
2026-04-27 08:55:16 +02:00
|
|
|
startWebAuthnAuth, verifyWebAuthnAuth,
|
2026-04-27 18:33:46 +02:00
|
|
|
startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile,
|
2026-04-27 19:30:54 +02:00
|
|
|
generateBackupCodes, createTotpSecret, verifyTotpSetup,
|
2026-04-27 18:33:46 +02:00
|
|
|
skipMfaEnrollment, submitInteraction,
|
|
|
|
|
MfaRequiredError, MfaEnrollmentError,
|
2026-04-26 13:39:31 +02:00
|
|
|
} from './experience-api';
|
2026-04-06 11:43:22 +02:00
|
|
|
import styles from './SignInPage.module.css';
|
|
|
|
|
|
2026-04-27 20:49:32 +02:00
|
|
|
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll' | 'mfaEnrollTotp' | 'mfaEnrollBackupCodes';
|
2026-04-25 00:21:07 +02:00
|
|
|
|
|
|
|
|
const SIGN_IN_SUBTITLES = [
|
2026-04-06 11:43:22 +02:00
|
|
|
"Prove you're not a mirage",
|
|
|
|
|
"Only authorized cameleers beyond this dune",
|
|
|
|
|
"Halt, traveler — state your business",
|
|
|
|
|
"The caravan doesn't move without credentials",
|
|
|
|
|
"No hitchhikers on this caravan",
|
|
|
|
|
"This oasis requires a password",
|
|
|
|
|
"Camels remember faces. We use passwords.",
|
|
|
|
|
"You shall not pass... without logging in",
|
|
|
|
|
"The desert is vast. Your session has expired.",
|
|
|
|
|
"Another day, another dune to authenticate",
|
|
|
|
|
"Papers, please. The caravan master is watching.",
|
|
|
|
|
"Trust, but verify — ancient cameleer proverb",
|
|
|
|
|
"Even the Silk Road had checkpoints",
|
|
|
|
|
"Your camel is parked outside. Now identify yourself.",
|
|
|
|
|
"One does not simply walk into the dashboard",
|
|
|
|
|
"The sands shift, but your password shouldn't",
|
|
|
|
|
"Unauthorized access? In this economy?",
|
|
|
|
|
"Welcome back, weary traveler",
|
|
|
|
|
"The dashboard awaits on the other side of this dune",
|
|
|
|
|
"Keep calm and authenticate",
|
|
|
|
|
"Who goes there? Friend or rogue exchange?",
|
|
|
|
|
"Access denied looks the same in every desert",
|
|
|
|
|
"May your routes be green and your tokens valid",
|
|
|
|
|
"Forgot your password? That's between you and the dunes.",
|
|
|
|
|
"No ticket, no caravan",
|
|
|
|
|
];
|
|
|
|
|
|
2026-04-25 00:21:07 +02:00
|
|
|
const REGISTER_SUBTITLES = [
|
|
|
|
|
"Every great journey starts with a single sign-up",
|
|
|
|
|
"Welcome to the caravan — let's get you registered",
|
|
|
|
|
"A new cameleer approaches the oasis",
|
|
|
|
|
"Join the caravan. We have dashboards.",
|
|
|
|
|
"The desert is better with company",
|
|
|
|
|
"First time here? The camels don't bite.",
|
|
|
|
|
"Pack your bags, you're joining the caravan",
|
|
|
|
|
"Room for one more on this caravan",
|
|
|
|
|
"New rider? Excellent. Credentials, please.",
|
|
|
|
|
"The Silk Road awaits — just need your email first",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function pickRandom(arr: string[]) {
|
|
|
|
|
return arr[Math.floor(Math.random() * arr.length)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getInitialMode(): Mode {
|
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
2026-04-25 10:10:57 +02:00
|
|
|
if (params.get('first_screen') === 'register') return 'register';
|
|
|
|
|
if (window.location.pathname.endsWith('/register')) return 'register';
|
|
|
|
|
return 'signIn';
|
2026-04-25 00:21:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 11:43:22 +02:00
|
|
|
export function SignInPage() {
|
2026-04-25 00:21:07 +02:00
|
|
|
const [mode, setMode] = useState<Mode>(getInitialMode);
|
2026-04-25 20:06:17 +02:00
|
|
|
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
2026-04-26 13:42:51 +02:00
|
|
|
const [emailConnectorConfigured, setEmailConnectorConfigured] = useState(false);
|
2026-04-25 00:21:07 +02:00
|
|
|
const subtitle = useMemo(
|
|
|
|
|
() => pickRandom(mode === 'signIn' ? SIGN_IN_SUBTITLES : REGISTER_SUBTITLES),
|
|
|
|
|
[mode === 'signIn' ? 'signIn' : 'register'],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [identifier, setIdentifier] = useState('');
|
2026-04-06 11:43:22 +02:00
|
|
|
const [password, setPassword] = useState('');
|
2026-04-25 00:21:07 +02:00
|
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
|
|
|
const [code, setCode] = useState('');
|
2026-04-09 19:49:14 +02:00
|
|
|
const [showPassword, setShowPassword] = useState(false);
|
2026-04-06 11:43:22 +02:00
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-04-25 00:21:07 +02:00
|
|
|
const [verificationId, setVerificationId] = useState('');
|
2026-04-26 13:42:51 +02:00
|
|
|
const [newPassword, setNewPassword] = useState('');
|
|
|
|
|
const [confirmNewPassword, setConfirmNewPassword] = useState('');
|
2026-04-27 08:55:16 +02:00
|
|
|
const [webauthnError, setWebauthnError] = useState('');
|
|
|
|
|
const [webauthnLoading, setWebauthnLoading] = useState(false);
|
2026-04-27 20:49:32 +02:00
|
|
|
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
|
|
|
|
const [backupCodesSaved, setBackupCodesSaved] = useState(false);
|
2026-04-25 00:21:07 +02:00
|
|
|
|
2026-04-25 20:06:17 +02:00
|
|
|
// Fetch sign-in experience to check if registration is enabled
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetch('/api/.well-known/sign-in-exp')
|
|
|
|
|
.then((r) => r.json())
|
|
|
|
|
.then((data) => {
|
|
|
|
|
const enabled = data.signInMode === 'SignInAndRegister';
|
|
|
|
|
setRegistrationEnabled(enabled);
|
|
|
|
|
if (!enabled && mode !== 'signIn') setMode('signIn');
|
2026-04-26 13:42:51 +02:00
|
|
|
const hasEmailConnector = Array.isArray(data.signUp?.identifiers) && data.signUp.identifiers.includes('email');
|
|
|
|
|
setEmailConnectorConfigured(hasEmailConnector);
|
2026-04-25 20:06:17 +02:00
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-25 00:21:07 +02:00
|
|
|
// Reset error when switching modes
|
|
|
|
|
useEffect(() => { setError(null); }, [mode]);
|
|
|
|
|
|
|
|
|
|
const switchMode = (next: Mode) => {
|
|
|
|
|
setMode(next);
|
|
|
|
|
setPassword('');
|
|
|
|
|
setConfirmPassword('');
|
2026-04-26 13:42:51 +02:00
|
|
|
setNewPassword('');
|
|
|
|
|
setConfirmNewPassword('');
|
2026-04-25 00:21:07 +02:00
|
|
|
setCode('');
|
|
|
|
|
setShowPassword(false);
|
|
|
|
|
setVerificationId('');
|
|
|
|
|
};
|
2026-04-06 11:43:22 +02:00
|
|
|
|
2026-04-25 00:21:07 +02:00
|
|
|
// --- Sign-in ---
|
|
|
|
|
const handleSignIn = async (e: FormEvent) => {
|
2026-04-06 11:43:22 +02:00
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
2026-04-25 00:21:07 +02:00
|
|
|
const redirectTo = await signIn(identifier, password);
|
2026-04-06 11:43:22 +02:00
|
|
|
window.location.replace(redirectTo);
|
|
|
|
|
} catch (err) {
|
2026-04-26 13:44:28 +02:00
|
|
|
if (err instanceof MfaRequiredError) {
|
2026-04-27 08:55:16 +02:00
|
|
|
const pref = localStorage.getItem('mfa_method_preference');
|
2026-04-27 20:49:32 +02:00
|
|
|
if (pref === 'totp') {
|
2026-04-27 08:55:16 +02:00
|
|
|
setMode('mfaVerify');
|
|
|
|
|
} else {
|
2026-04-27 20:49:32 +02:00
|
|
|
setMode('mfaWebauthn');
|
2026-04-27 08:55:16 +02:00
|
|
|
}
|
2026-04-26 13:44:28 +02:00
|
|
|
setLoading(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-27 18:33:46 +02:00
|
|
|
if (err instanceof MfaEnrollmentError) {
|
|
|
|
|
setMode('mfaEnroll');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-06 11:43:22 +02:00
|
|
|
setError(err instanceof Error ? err.message : 'Sign-in failed');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-25 00:21:07 +02:00
|
|
|
// --- Register step 1: send verification code ---
|
|
|
|
|
const handleRegister = async (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
2026-04-25 09:41:41 +02:00
|
|
|
if (!identifier.includes('@')) {
|
|
|
|
|
setError('Please enter a valid email address');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-25 00:21:07 +02:00
|
|
|
if (password !== confirmPassword) {
|
|
|
|
|
setError('Passwords do not match');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (password.length < 8) {
|
|
|
|
|
setError('Password must be at least 8 characters');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const vId = await startRegistration(identifier);
|
|
|
|
|
setVerificationId(vId);
|
|
|
|
|
setMode('verifyCode');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Registration failed');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// --- Register step 2: verify code + complete ---
|
|
|
|
|
const handleVerifyCode = async (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
|
|
|
|
|
window.location.replace(redirectTo);
|
|
|
|
|
} catch (err) {
|
2026-04-27 18:33:46 +02:00
|
|
|
if (err instanceof MfaEnrollmentError) {
|
|
|
|
|
setMode('mfaEnroll');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-25 00:21:07 +02:00
|
|
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 13:42:51 +02:00
|
|
|
// --- Forgot password step 1: send reset code ---
|
|
|
|
|
const handleForgotPassword = async (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
if (!identifier.includes('@')) {
|
|
|
|
|
setError('Please enter your email address');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const vId = await startForgotPassword(identifier);
|
|
|
|
|
setVerificationId(vId);
|
|
|
|
|
setMode('forgotPasswordVerify');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to send reset code');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// --- Forgot password step 2: verify code + set new password ---
|
|
|
|
|
const handleForgotPasswordVerify = async (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
if (newPassword !== confirmNewPassword) {
|
|
|
|
|
setError('Passwords do not match');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (newPassword.length < 8) {
|
|
|
|
|
setError('Password must be at least 8 characters');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
await forgotPasswordVerifyAndReset(identifier, verificationId, code, newPassword);
|
|
|
|
|
// Send security notification email (fire-and-forget)
|
|
|
|
|
fetch('/platform/api/password-reset-notification', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ email: identifier }),
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
// Reset to sign-in with success message
|
|
|
|
|
switchMode('signIn');
|
|
|
|
|
setError(null);
|
|
|
|
|
setIdentifier(identifier); // preserve email for convenience
|
|
|
|
|
alert('Password reset successful. Please sign in with your new password.');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Password reset failed');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-26 13:44:28 +02:00
|
|
|
// --- MFA: TOTP verification ---
|
|
|
|
|
const handleMfaVerify = async (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const verificationId = await verifyTotp(code);
|
2026-04-27 08:55:16 +02:00
|
|
|
localStorage.setItem('mfa_method_preference', 'totp');
|
2026-04-26 13:44:28 +02:00
|
|
|
const redirectTo = await submitMfa(verificationId);
|
|
|
|
|
window.location.replace(redirectTo);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 08:55:16 +02:00
|
|
|
// --- MFA: WebAuthn/passkey verification ---
|
|
|
|
|
async function handleWebAuthnVerify() {
|
|
|
|
|
setWebauthnError('');
|
|
|
|
|
setWebauthnLoading(true);
|
|
|
|
|
try {
|
2026-04-27 20:49:32 +02:00
|
|
|
const { verificationId, authenticationOptions } = await startWebAuthnAuth();
|
|
|
|
|
const credential = await startAuthentication({ optionsJSON: authenticationOptions as any });
|
|
|
|
|
await verifyWebAuthnAuth(verificationId, credential as unknown as Record<string, unknown>);
|
2026-04-27 08:55:16 +02:00
|
|
|
localStorage.setItem('mfa_method_preference', 'webauthn');
|
|
|
|
|
const redirectTo = await submitMfa(verificationId);
|
|
|
|
|
window.location.replace(redirectTo);
|
|
|
|
|
} catch (err) {
|
2026-04-27 20:49:32 +02:00
|
|
|
if (err instanceof Error && err.name === 'NotAllowedError') {
|
|
|
|
|
setWebauthnLoading(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-27 08:55:16 +02:00
|
|
|
setWebauthnError(err instanceof Error ? err.message : 'Passkey verification failed');
|
|
|
|
|
setWebauthnLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-trigger passkey prompt when entering mfaWebauthn mode
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (mode === 'mfaWebauthn') {
|
|
|
|
|
handleWebAuthnVerify();
|
|
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [mode]);
|
|
|
|
|
|
2026-04-26 13:44:28 +02:00
|
|
|
// --- MFA: backup code verification ---
|
|
|
|
|
const handleBackupCodeVerify = async (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const verificationId = await verifyBackupCode(code);
|
|
|
|
|
const redirectTo = await submitMfa(verificationId);
|
|
|
|
|
window.location.replace(redirectTo);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-27 19:22:53 +02:00
|
|
|
// --- MFA enrollment ---
|
|
|
|
|
const [totpSetup, setTotpSetup] = useState<{ secret: string; secretQrCode: string; verificationId: string } | null>(null);
|
|
|
|
|
const [totpCode, setTotpCode] = useState('');
|
|
|
|
|
|
2026-04-27 18:33:46 +02:00
|
|
|
async function handleEnrollPasskey() {
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const { verificationId, registrationOptions } = await startWebAuthnRegistration();
|
|
|
|
|
const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any });
|
|
|
|
|
const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record<string, unknown>);
|
|
|
|
|
await bindMfaProfile('WebAuthn', verifiedId);
|
2026-04-27 19:30:54 +02:00
|
|
|
const bc = await generateBackupCodes();
|
|
|
|
|
await bindMfaProfile('BackupCode', bc.verificationId);
|
2026-04-27 20:49:32 +02:00
|
|
|
setBackupCodes(bc.codes);
|
|
|
|
|
setBackupCodesSaved(false);
|
|
|
|
|
setMode('mfaEnrollBackupCodes');
|
|
|
|
|
setLoading(false);
|
2026-04-27 18:33:46 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
if (err instanceof Error && err.name === 'NotAllowedError') {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Passkey registration failed');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 19:22:53 +02:00
|
|
|
async function handleStartTotpEnroll() {
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const data = await createTotpSecret();
|
|
|
|
|
setTotpSetup(data);
|
|
|
|
|
setTotpCode('');
|
|
|
|
|
setMode('mfaEnrollTotp');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to start TOTP setup');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleVerifyTotpEnroll(e: FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const verifiedId = await verifyTotpSetup(totpCode);
|
|
|
|
|
await bindMfaProfile('Totp', verifiedId);
|
2026-04-27 19:30:54 +02:00
|
|
|
const bc = await generateBackupCodes();
|
|
|
|
|
await bindMfaProfile('BackupCode', bc.verificationId);
|
2026-04-27 20:49:32 +02:00
|
|
|
setBackupCodes(bc.codes);
|
|
|
|
|
setBackupCodesSaved(false);
|
|
|
|
|
setMode('mfaEnrollBackupCodes');
|
|
|
|
|
setLoading(false);
|
2026-04-27 19:22:53 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Verification failed');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 20:49:32 +02:00
|
|
|
async function handleBackupCodesDone() {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const redirectTo = await submitInteraction();
|
|
|
|
|
window.location.replace(redirectTo);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to complete sign-in');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 18:33:46 +02:00
|
|
|
async function handleSkipEnrollment() {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const redirectTo = await skipMfaEnrollment();
|
|
|
|
|
window.location.replace(redirectTo);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Failed to continue');
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 00:21:07 +02:00
|
|
|
const passwordToggle = (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
|
|
|
className={styles.passwordToggle}
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
>
|
|
|
|
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-06 11:43:22 +02:00
|
|
|
return (
|
|
|
|
|
<div className={styles.page}>
|
|
|
|
|
<Card className={styles.card}>
|
2026-04-25 00:21:07 +02:00
|
|
|
<div className={styles.formContainer}>
|
2026-04-06 11:43:22 +02:00
|
|
|
<div className={styles.logo}>
|
2026-04-06 22:39:29 +02:00
|
|
|
<img src={cameleerLogo} alt="" className={styles.logoImg} />
|
2026-04-09 19:49:14 +02:00
|
|
|
Cameleer
|
2026-04-06 11:43:22 +02:00
|
|
|
</div>
|
|
|
|
|
<p className={styles.subtitle}>{subtitle}</p>
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<div className={styles.error}>
|
|
|
|
|
<Alert variant="error">{error}</Alert>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-25 00:21:07 +02:00
|
|
|
{/* --- Sign-in form --- */}
|
|
|
|
|
{mode === 'signIn' && (
|
|
|
|
|
<form className={styles.fields} onSubmit={handleSignIn} aria-label="Sign in" noValidate>
|
2026-04-25 20:46:24 +02:00
|
|
|
<FormField label="Login" htmlFor="login-identifier">
|
2026-04-25 00:21:07 +02:00
|
|
|
<Input
|
|
|
|
|
id="login-identifier"
|
|
|
|
|
type="email"
|
|
|
|
|
value={identifier}
|
|
|
|
|
onChange={(e) => setIdentifier(e.target.value)}
|
|
|
|
|
placeholder="you@company.com"
|
|
|
|
|
autoFocus
|
|
|
|
|
autoComplete="username"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<FormField label="Password" htmlFor="login-password">
|
|
|
|
|
<div className={styles.passwordWrapper}>
|
|
|
|
|
<Input
|
|
|
|
|
id="login-password"
|
|
|
|
|
type={showPassword ? 'text' : 'password'}
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
placeholder="••••••••"
|
|
|
|
|
autoComplete="current-password"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
{passwordToggle}
|
|
|
|
|
</div>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
type="submit"
|
|
|
|
|
loading={loading}
|
|
|
|
|
disabled={loading || !identifier || !password}
|
|
|
|
|
className={styles.submitButton}
|
|
|
|
|
>
|
|
|
|
|
Sign in
|
|
|
|
|
</Button>
|
|
|
|
|
|
2026-04-27 16:05:50 +02:00
|
|
|
{emailConnectorConfigured && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={styles.forgotLink}
|
|
|
|
|
onClick={() => { setError(null); setMode('forgotPassword'); }}
|
|
|
|
|
>
|
|
|
|
|
Forgot password?
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-25 20:06:17 +02:00
|
|
|
{registrationEnabled && (
|
|
|
|
|
<p className={styles.switchText}>
|
|
|
|
|
Don't have an account?{' '}
|
|
|
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
|
|
|
|
Sign up
|
|
|
|
|
</button>
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2026-04-25 00:21:07 +02:00
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* --- Register form --- */}
|
|
|
|
|
{mode === 'register' && (
|
|
|
|
|
<form className={styles.fields} onSubmit={handleRegister} aria-label="Create account" noValidate>
|
|
|
|
|
<FormField label="Email" htmlFor="register-email">
|
|
|
|
|
<Input
|
|
|
|
|
id="register-email"
|
|
|
|
|
type="email"
|
|
|
|
|
value={identifier}
|
|
|
|
|
onChange={(e) => setIdentifier(e.target.value)}
|
|
|
|
|
placeholder="you@company.com"
|
|
|
|
|
autoFocus
|
|
|
|
|
autoComplete="email"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<FormField label="Password" htmlFor="register-password">
|
|
|
|
|
<div className={styles.passwordWrapper}>
|
|
|
|
|
<Input
|
|
|
|
|
id="register-password"
|
|
|
|
|
type={showPassword ? 'text' : 'password'}
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
placeholder="At least 8 characters"
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
{passwordToggle}
|
|
|
|
|
</div>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<FormField label="Confirm password" htmlFor="register-confirm">
|
2026-04-09 19:49:14 +02:00
|
|
|
<Input
|
2026-04-25 00:21:07 +02:00
|
|
|
id="register-confirm"
|
2026-04-09 19:49:14 +02:00
|
|
|
type={showPassword ? 'text' : 'password'}
|
2026-04-25 00:21:07 +02:00
|
|
|
value={confirmPassword}
|
|
|
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
2026-04-09 19:49:14 +02:00
|
|
|
placeholder="••••••••"
|
2026-04-25 00:21:07 +02:00
|
|
|
autoComplete="new-password"
|
2026-04-09 19:49:14 +02:00
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
2026-04-25 00:21:07 +02:00
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
type="submit"
|
|
|
|
|
loading={loading}
|
|
|
|
|
disabled={loading || !identifier || !password || !confirmPassword}
|
|
|
|
|
className={styles.submitButton}
|
|
|
|
|
>
|
|
|
|
|
Create account
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<p className={styles.switchText}>
|
|
|
|
|
Already have an account?{' '}
|
|
|
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
|
|
|
|
|
Sign in
|
2026-04-09 19:49:14 +02:00
|
|
|
</button>
|
2026-04-25 00:21:07 +02:00
|
|
|
</p>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* --- Verification code form --- */}
|
|
|
|
|
{mode === 'verifyCode' && (
|
|
|
|
|
<form className={styles.fields} onSubmit={handleVerifyCode} aria-label="Verify email" noValidate>
|
|
|
|
|
<p className={styles.verifyHint}>
|
|
|
|
|
We sent a verification code to <strong>{identifier}</strong>
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<FormField label="Verification code" htmlFor="verify-code">
|
|
|
|
|
<Input
|
|
|
|
|
id="verify-code"
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
value={code}
|
|
|
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
|
|
|
placeholder="000000"
|
|
|
|
|
autoFocus
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
type="submit"
|
|
|
|
|
loading={loading}
|
|
|
|
|
disabled={loading || code.length < 6}
|
|
|
|
|
className={styles.submitButton}
|
|
|
|
|
>
|
|
|
|
|
Verify & create account
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<p className={styles.switchText}>
|
|
|
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('register')}>
|
|
|
|
|
Back
|
|
|
|
|
</button>
|
|
|
|
|
</p>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
2026-04-26 13:42:51 +02:00
|
|
|
|
|
|
|
|
{/* --- Forgot password: email entry --- */}
|
|
|
|
|
{mode === 'forgotPassword' && (
|
|
|
|
|
<form className={styles.fields} onSubmit={handleForgotPassword} aria-label="Reset password" noValidate>
|
|
|
|
|
<p className={styles.verifyHint}>
|
|
|
|
|
Enter your email address and we'll send you a code to reset your password.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<FormField label="Email" htmlFor="forgot-email">
|
|
|
|
|
<Input
|
|
|
|
|
id="forgot-email"
|
|
|
|
|
type="email"
|
|
|
|
|
value={identifier}
|
|
|
|
|
onChange={(e) => setIdentifier(e.target.value)}
|
|
|
|
|
placeholder="you@company.com"
|
|
|
|
|
autoFocus
|
|
|
|
|
autoComplete="email"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
type="submit"
|
|
|
|
|
loading={loading}
|
|
|
|
|
disabled={loading || !identifier}
|
|
|
|
|
className={styles.submitButton}
|
|
|
|
|
>
|
|
|
|
|
Send reset code
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<p className={styles.switchText}>
|
|
|
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
|
|
|
|
|
Back to sign in
|
|
|
|
|
</button>
|
|
|
|
|
</p>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* --- Forgot password: verify code + new password --- */}
|
|
|
|
|
{mode === 'forgotPasswordVerify' && (
|
|
|
|
|
<form className={styles.fields} onSubmit={handleForgotPasswordVerify} aria-label="Set new password" noValidate>
|
|
|
|
|
<p className={styles.verifyHint}>
|
|
|
|
|
We sent a verification code to <strong>{identifier}</strong>
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<FormField label="Verification code" htmlFor="forgot-code">
|
|
|
|
|
<Input
|
|
|
|
|
id="forgot-code"
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
value={code}
|
|
|
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
|
|
|
placeholder="000000"
|
|
|
|
|
autoFocus
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<FormField label="New password" htmlFor="forgot-new-password">
|
|
|
|
|
<div className={styles.passwordWrapper}>
|
|
|
|
|
<Input
|
|
|
|
|
id="forgot-new-password"
|
|
|
|
|
type={showPassword ? 'text' : 'password'}
|
|
|
|
|
value={newPassword}
|
|
|
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
|
|
|
placeholder="At least 8 characters"
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
{passwordToggle}
|
|
|
|
|
</div>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<FormField label="Confirm new password" htmlFor="forgot-confirm-password">
|
|
|
|
|
<Input
|
|
|
|
|
id="forgot-confirm-password"
|
|
|
|
|
type={showPassword ? 'text' : 'password'}
|
|
|
|
|
value={confirmNewPassword}
|
|
|
|
|
onChange={(e) => setConfirmNewPassword(e.target.value)}
|
|
|
|
|
placeholder="••••••••"
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
type="submit"
|
|
|
|
|
loading={loading}
|
|
|
|
|
disabled={loading || code.length < 6 || !newPassword || !confirmNewPassword}
|
|
|
|
|
className={styles.submitButton}
|
|
|
|
|
>
|
|
|
|
|
Reset password
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<p className={styles.switchText}>
|
|
|
|
|
<button type="button" className={styles.switchLink} onClick={() => switchMode('forgotPassword')}>
|
|
|
|
|
Back
|
|
|
|
|
</button>
|
|
|
|
|
</p>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
2026-04-26 13:44:28 +02:00
|
|
|
{/* --- MFA: TOTP verification --- */}
|
|
|
|
|
{mode === 'mfaVerify' && (
|
|
|
|
|
<form className={styles.fields} onSubmit={handleMfaVerify} aria-label="Two-factor authentication" noValidate>
|
|
|
|
|
<p className={styles.verifyHint}>
|
|
|
|
|
Enter the 6-digit code from your authenticator app.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<FormField label="Authentication code" htmlFor="mfa-totp-code">
|
|
|
|
|
<Input
|
|
|
|
|
id="mfa-totp-code"
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
value={code}
|
|
|
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
|
|
|
placeholder="000000"
|
|
|
|
|
autoFocus
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
type="submit"
|
|
|
|
|
loading={loading}
|
|
|
|
|
disabled={loading || code.length < 6}
|
|
|
|
|
className={styles.submitButton}
|
|
|
|
|
>
|
|
|
|
|
Verify
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<div className={styles.backupCodeCard}>
|
|
|
|
|
<p className={styles.backupCodeText}>Lost your device?</p>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={styles.backupCodeAction}
|
|
|
|
|
onClick={() => { setCode(''); setError(null); setMode('mfaBackupCode'); }}
|
|
|
|
|
>
|
|
|
|
|
Use a backup code
|
|
|
|
|
</button>
|
2026-04-27 08:55:16 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={styles.backupCodeAction}
|
|
|
|
|
onClick={() => { setCode(''); setError(null); setMode('mfaWebauthn'); }}
|
|
|
|
|
>
|
|
|
|
|
Use passkey instead
|
|
|
|
|
</button>
|
2026-04-26 13:44:28 +02:00
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* --- MFA: backup code verification --- */}
|
|
|
|
|
{mode === 'mfaBackupCode' && (
|
|
|
|
|
<form className={styles.fields} onSubmit={handleBackupCodeVerify} aria-label="Backup code verification" noValidate>
|
|
|
|
|
<p className={styles.verifyHint}>
|
|
|
|
|
Enter one of your 10 backup codes.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<FormField label="Backup code" htmlFor="mfa-backup-code">
|
|
|
|
|
<Input
|
|
|
|
|
id="mfa-backup-code"
|
|
|
|
|
type="text"
|
|
|
|
|
value={code}
|
|
|
|
|
onChange={(e) => setCode(e.target.value)}
|
|
|
|
|
placeholder="Enter backup code"
|
|
|
|
|
autoFocus
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
type="submit"
|
|
|
|
|
loading={loading}
|
|
|
|
|
disabled={loading || !code}
|
|
|
|
|
className={styles.submitButton}
|
|
|
|
|
>
|
|
|
|
|
Verify backup code
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
<p className={styles.switchText}>
|
|
|
|
|
<button type="button" className={styles.switchLink} onClick={() => { setCode(''); setError(null); setMode('mfaVerify'); }}>
|
|
|
|
|
Use authenticator app instead
|
|
|
|
|
</button>
|
|
|
|
|
</p>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
2026-04-27 08:55:16 +02:00
|
|
|
|
|
|
|
|
{/* --- MFA: method picker --- */}
|
|
|
|
|
{mode === 'mfaMethodPicker' && (
|
|
|
|
|
<div className={styles.fields}>
|
|
|
|
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
|
|
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Verify your identity</h2>
|
|
|
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>Choose a verification method</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
|
|
|
<Button variant="primary" onClick={() => setMode('mfaWebauthn')}>
|
|
|
|
|
Use passkey
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="secondary" onClick={() => setMode('mfaVerify')}>
|
|
|
|
|
Use authenticator code
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* --- MFA: WebAuthn/passkey verification --- */}
|
|
|
|
|
{mode === 'mfaWebauthn' && (
|
|
|
|
|
<div className={styles.fields}>
|
|
|
|
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
|
|
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Passkey verification</h2>
|
|
|
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
|
|
|
|
Use your fingerprint, face, or security key
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
{webauthnError && <Alert variant="error">{webauthnError}</Alert>}
|
|
|
|
|
<Button variant="primary" onClick={handleWebAuthnVerify} loading={webauthnLoading} style={{ width: '100%' }}>
|
|
|
|
|
Verify with passkey
|
|
|
|
|
</Button>
|
|
|
|
|
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
|
|
|
|
<button type="button" className={styles.switchLink} onClick={() => setMode('mfaVerify')}>
|
|
|
|
|
Use authenticator code instead
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-27 18:33:46 +02:00
|
|
|
|
2026-04-27 19:22:53 +02:00
|
|
|
{/* --- MFA enrollment: choose method --- */}
|
2026-04-27 18:33:46 +02:00
|
|
|
{mode === 'mfaEnroll' && (
|
|
|
|
|
<div className={styles.fields}>
|
|
|
|
|
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
|
|
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Secure your account</h2>
|
|
|
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
2026-04-27 19:22:53 +02:00
|
|
|
Add an extra layer of security to your account.
|
2026-04-27 18:33:46 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
|
|
|
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
|
2026-04-27 19:22:53 +02:00
|
|
|
Use passkey
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="secondary" onClick={handleStartTotpEnroll} disabled={loading}>
|
|
|
|
|
Use authenticator app
|
2026-04-27 18:33:46 +02:00
|
|
|
</Button>
|
|
|
|
|
<Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}>
|
|
|
|
|
Set up later
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-27 19:22:53 +02:00
|
|
|
|
|
|
|
|
{/* --- MFA enrollment: TOTP setup --- */}
|
|
|
|
|
{mode === 'mfaEnrollTotp' && totpSetup && (
|
|
|
|
|
<div className={styles.fields}>
|
|
|
|
|
<div style={{ textAlign: 'center', marginBottom: 8 }}>
|
|
|
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Set up authenticator</h2>
|
|
|
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
|
|
|
|
Scan this QR code with your authenticator app, then enter the 6-digit code.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
|
|
|
|
<img src={totpSetup.secretQrCode} alt="TOTP QR Code" width={180} height={180} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{
|
|
|
|
|
textAlign: 'center', padding: '6px 10px',
|
|
|
|
|
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
|
|
|
|
|
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.7rem',
|
|
|
|
|
wordBreak: 'break-all',
|
|
|
|
|
}}>
|
|
|
|
|
{totpSetup.secret}
|
|
|
|
|
</div>
|
|
|
|
|
<form onSubmit={handleVerifyTotpEnroll} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
|
|
|
<FormField label="Verification code" htmlFor="enroll-totp-code">
|
|
|
|
|
<Input
|
|
|
|
|
id="enroll-totp-code"
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
maxLength={6}
|
|
|
|
|
value={totpCode}
|
|
|
|
|
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
|
|
|
placeholder="Enter 6-digit code"
|
|
|
|
|
autoFocus
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
/>
|
|
|
|
|
</FormField>
|
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
|
|
|
<Button type="submit" variant="primary" loading={loading} disabled={totpCode.length !== 6}>
|
|
|
|
|
Verify & Enable
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="button" variant="secondary" onClick={() => { setTotpSetup(null); setMode('mfaEnroll'); }} disabled={loading}>
|
|
|
|
|
Back
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-27 20:49:32 +02:00
|
|
|
|
|
|
|
|
{/* --- MFA enrollment: backup codes --- */}
|
|
|
|
|
{mode === 'mfaEnrollBackupCodes' && backupCodes && (
|
|
|
|
|
<div className={styles.fields}>
|
|
|
|
|
<div style={{ textAlign: 'center', marginBottom: 8 }}>
|
|
|
|
|
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Save your backup codes</h2>
|
|
|
|
|
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
|
|
|
|
|
Store these codes safely. Each can be used once to sign in if you lose access to your authenticator or passkey.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{
|
|
|
|
|
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
|
|
|
|
gap: '6px 20px', padding: 12,
|
|
|
|
|
background: 'var(--bg-inset, #f5f5f5)', border: '1px solid var(--border, #e0e0e0)',
|
|
|
|
|
borderRadius: 6, fontFamily: 'monospace', fontSize: '0.85rem',
|
|
|
|
|
}}>
|
|
|
|
|
{backupCodes.map((c) => <span key={c}>{c}</span>)}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
|
|
|
|
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(backupCodes.join('\n'))}>
|
|
|
|
|
Copy all
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="secondary" onClick={() => {
|
|
|
|
|
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain' });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = url; a.download = 'cameleer-backup-codes.txt'; a.click();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}}>
|
|
|
|
|
Download
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '0.875rem', cursor: 'pointer' }}>
|
|
|
|
|
<input type="checkbox" checked={backupCodesSaved} onChange={(e) => setBackupCodesSaved(e.target.checked)} />
|
|
|
|
|
I've saved my backup codes
|
|
|
|
|
</label>
|
|
|
|
|
<Button variant="primary" disabled={!backupCodesSaved} onClick={handleBackupCodesDone} loading={loading}>
|
|
|
|
|
Continue
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-06 11:43:22 +02:00
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|