Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
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<LoginState>('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 (
|
|
<Container size={420} my={80}>
|
|
<Center>
|
|
<img
|
|
src={logoSrc}
|
|
alt="HOA LedgerIQ"
|
|
style={{
|
|
height: 60,
|
|
...(isDark
|
|
? {
|
|
filter:
|
|
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
|
}
|
|
: {}),
|
|
}}
|
|
/>
|
|
</Center>
|
|
|
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
|
<Stack>
|
|
<Group gap="xs" justify="center">
|
|
<IconShieldLock size={24} />
|
|
<Text fw={600} size="lg">
|
|
Two-Factor Authentication
|
|
</Text>
|
|
</Group>
|
|
|
|
{error && (
|
|
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{!useRecovery ? (
|
|
<>
|
|
<Text size="sm" c="dimmed" ta="center">
|
|
Enter the 6-digit code from your authenticator app
|
|
</Text>
|
|
<Center>
|
|
<PinInput
|
|
length={6}
|
|
type="number"
|
|
value={mfaCode}
|
|
onChange={setMfaCode}
|
|
oneTimeCode
|
|
autoFocus
|
|
size="lg"
|
|
/>
|
|
</Center>
|
|
<Button
|
|
fullWidth
|
|
loading={loading}
|
|
onClick={handleMfaVerify}
|
|
disabled={mfaCode.length !== 6}
|
|
>
|
|
Verify
|
|
</Button>
|
|
<Anchor
|
|
size="sm"
|
|
ta="center"
|
|
onClick={() => {
|
|
setUseRecovery(true);
|
|
setError('');
|
|
}}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
Use a recovery code instead
|
|
</Anchor>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text size="sm" c="dimmed" ta="center">
|
|
Enter one of your recovery codes
|
|
</Text>
|
|
<TextInput
|
|
placeholder="xxxxxxxx"
|
|
value={recoveryCode}
|
|
onChange={(e) => setRecoveryCode(e.currentTarget.value)}
|
|
autoFocus
|
|
ff="monospace"
|
|
/>
|
|
<Button
|
|
fullWidth
|
|
loading={loading}
|
|
onClick={handleMfaVerify}
|
|
disabled={!recoveryCode.trim()}
|
|
>
|
|
Verify Recovery Code
|
|
</Button>
|
|
<Anchor
|
|
size="sm"
|
|
ta="center"
|
|
onClick={() => {
|
|
setUseRecovery(false);
|
|
setError('');
|
|
}}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
Use authenticator code instead
|
|
</Anchor>
|
|
</>
|
|
)}
|
|
|
|
<Anchor
|
|
size="sm"
|
|
ta="center"
|
|
onClick={() => {
|
|
setLoginState('credentials');
|
|
setMfaToken('');
|
|
setMfaCode('');
|
|
setRecoveryCode('');
|
|
setError('');
|
|
}}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
← Back to login
|
|
</Anchor>
|
|
</Stack>
|
|
</Paper>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
// Main login form
|
|
return (
|
|
<Container size={420} my={80}>
|
|
<Center>
|
|
<img
|
|
src={logoSrc}
|
|
alt="HOA LedgerIQ"
|
|
style={{
|
|
height: 60,
|
|
...(isDark
|
|
? {
|
|
filter:
|
|
'drop-shadow(0 0 1px rgba(255,255,255,0.8)) drop-shadow(0 0 2px rgba(255,255,255,0.4))',
|
|
}
|
|
: {}),
|
|
}}
|
|
/>
|
|
</Center>
|
|
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
|
Don't have an account?{' '}
|
|
<Anchor component={Link} to="/register" size="sm">
|
|
Register
|
|
</Anchor>
|
|
</Text>
|
|
|
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
|
<Stack>
|
|
{error && (
|
|
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
<TextInput
|
|
label="Email"
|
|
placeholder="your@email.com"
|
|
required
|
|
{...form.getInputProps('email')}
|
|
/>
|
|
<PasswordInput
|
|
label="Password"
|
|
placeholder="Your password"
|
|
required
|
|
{...form.getInputProps('password')}
|
|
/>
|
|
<Button type="submit" fullWidth loading={loading}>
|
|
Sign in
|
|
</Button>
|
|
</Stack>
|
|
</form>
|
|
|
|
{/* Passkey login */}
|
|
{passkeySupported && (
|
|
<>
|
|
<Divider label="or" labelPosition="center" my="md" />
|
|
<Button
|
|
variant="light"
|
|
fullWidth
|
|
leftSection={<IconFingerprint size={18} />}
|
|
onClick={handlePasskeyLogin}
|
|
loading={loading}
|
|
>
|
|
Sign in with Passkey
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{/* SSO providers */}
|
|
{hasSso && (
|
|
<>
|
|
<Divider label="or continue with" labelPosition="center" my="md" />
|
|
<Group grow>
|
|
{ssoProviders.google && (
|
|
<Button
|
|
variant="default"
|
|
leftSection={<IconBrandGoogle size={18} color="#4285F4" />}
|
|
onClick={() => {
|
|
window.location.href = '/api/auth/google';
|
|
}}
|
|
>
|
|
Google
|
|
</Button>
|
|
)}
|
|
{ssoProviders.azure && (
|
|
<Button
|
|
variant="default"
|
|
leftSection={<IconBrandWindows size={18} color="#0078D4" />}
|
|
onClick={() => {
|
|
window.location.href = '/api/auth/azure';
|
|
}}
|
|
>
|
|
Microsoft
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
</>
|
|
)}
|
|
</Paper>
|
|
</Container>
|
|
);
|
|
}
|