import { useState, useEffect } from 'react'; import { Center, Container, Paper, Text, TextInput, PasswordInput, Button, Anchor, Stack, Alert, Divider, Group, PinInput, } from '@mantine/core'; import { useForm } from '@mantine/form'; import { IconAlertCircle, IconBrandGoogle, IconBrandWindows, IconFingerprint, IconShieldLock, } from '@tabler/icons-react'; import { useNavigate, Link } from 'react-router-dom'; import { startAuthentication } from '@simplewebauthn/browser'; import api from '../../services/api'; import { useAuthStore } from '../../stores/authStore'; import { usePreferencesStore } from '../../stores/preferencesStore'; import logoSrc from '../../assets/logo.png'; type LoginState = 'credentials' | 'mfa'; export function LoginPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [loginState, setLoginState] = useState('credentials'); const [mfaToken, setMfaToken] = useState(''); const [mfaCode, setMfaCode] = useState(''); const [useRecovery, setUseRecovery] = useState(false); const [recoveryCode, setRecoveryCode] = useState(''); const [ssoProviders, setSsoProviders] = useState<{ google: boolean; azure: boolean }>({ google: false, azure: false, }); const [passkeySupported, setPasskeySupported] = useState(false); const navigate = useNavigate(); const setAuth = useAuthStore((s) => s.setAuth); const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark'; const form = useForm({ initialValues: { email: '', password: '' }, validate: { email: (v) => (/^\S+@\S+$/.test(v) ? null : 'Invalid email'), password: (v) => (v.length >= 1 ? null : 'Password required'), }, }); // Fetch SSO providers & check passkey support on mount useEffect(() => { api .get('/auth/sso/providers') .then(({ data }) => setSsoProviders(data)) .catch(() => {}); if ( window.PublicKeyCredential && typeof window.PublicKeyCredential === 'function' ) { setPasskeySupported(true); } }, []); const handleLoginSuccess = (data: any) => { setAuth(data.accessToken, data.user, data.organizations); if (data.user?.isSuperadmin && data.organizations.length === 0) { navigate('/admin'); } else if (data.organizations.length >= 1) { navigate('/select-org'); } else { navigate('/'); } }; const handleSubmit = async (values: typeof form.values) => { setLoading(true); setError(''); try { const { data } = await api.post('/auth/login', values); if (data.mfaRequired) { setMfaToken(data.mfaToken); setLoginState('mfa'); } else { handleLoginSuccess(data); } } catch (err: any) { setError(err.response?.data?.message || 'Login failed'); } finally { setLoading(false); } }; const handleMfaVerify = async () => { setLoading(true); setError(''); try { const token = useRecovery ? recoveryCode : mfaCode; const { data } = await api.post('/auth/mfa/verify', { mfaToken, token, isRecoveryCode: useRecovery, }); handleLoginSuccess(data); } catch (err: any) { setError(err.response?.data?.message || 'MFA verification failed'); } finally { setLoading(false); } }; const handlePasskeyLogin = async () => { setLoading(true); setError(''); try { // Get authentication options const { data: options } = await api.post('/auth/passkeys/login-options', { email: form.values.email || undefined, }); // Trigger browser WebAuthn prompt const credential = await startAuthentication({ optionsJSON: options }); // Verify with server const { data } = await api.post('/auth/passkeys/login', { response: credential, challenge: options.challenge, }); handleLoginSuccess(data); } catch (err: any) { if (err.name === 'NotAllowedError') { setError('Passkey authentication was cancelled'); } else { setError(err.response?.data?.message || err.message || 'Passkey login failed'); } } finally { setLoading(false); } }; const hasSso = ssoProviders.google || ssoProviders.azure; // MFA verification screen if (loginState === 'mfa') { return (
HOA LedgerIQ
Two-Factor Authentication {error && ( } color="red" variant="light"> {error} )} {!useRecovery ? ( <> Enter the 6-digit code from your authenticator app
{ setUseRecovery(true); setError(''); }} style={{ cursor: 'pointer' }} > Use a recovery code instead ) : ( <> Enter one of your recovery codes setRecoveryCode(e.currentTarget.value)} autoFocus ff="monospace" /> { setUseRecovery(false); setError(''); }} style={{ cursor: 'pointer' }} > Use authenticator code instead )} { setLoginState('credentials'); setMfaToken(''); setMfaCode(''); setRecoveryCode(''); setError(''); }} style={{ cursor: 'pointer' }} > ← Back to login
); } // Main login form return (
HOA LedgerIQ
Don't have an account?{' '} Register
{error && ( } color="red" variant="light"> {error} )}
{/* Passkey login */} {passkeySupported && ( <> )} {/* SSO providers */} {hasSso && ( <> {ssoProviders.google && ( )} {ssoProviders.azure && ( )} )}
); }