feat: SaaS onboarding, Stripe billing, MFA, SSO, passkeys, refresh tokens

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>
This commit is contained in:
2026-03-16 21:12:35 -04:00
parent 17bdebfb52
commit dfcd172ef3
39 changed files with 4673 additions and 82 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.03.10",
"version": "2026.3.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-frontend",
"version": "2026.03.10",
"version": "2026.3.11",
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3",
@@ -14,6 +14,7 @@
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@simplewebauthn/browser": "^13.3.0",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",
@@ -1289,6 +1290,12 @@
"win32"
]
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@tabler/icons": {
"version": "3.36.1",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.03.16",
"version": "2026.3.17",
"private": true,
"type": "module",
"scripts": {
@@ -16,6 +16,7 @@
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@simplewebauthn/browser": "^13.3.0",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.2",
"axios": "^1.7.9",

View File

@@ -4,6 +4,7 @@ import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/auth/LoginPage';
import { RegisterPage } from './pages/auth/RegisterPage';
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
import { ActivatePage } from './pages/auth/ActivatePage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
import { AccountsPage } from './pages/accounts/AccountsPage';
import { TransactionsPage } from './pages/transactions/TransactionsPage';
@@ -37,6 +38,9 @@ import { AssessmentScenariosPage } from './pages/board-planning/AssessmentScenar
import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentScenarioDetailPage';
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
import { PricingPage } from './pages/pricing/PricingPage';
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
@@ -77,6 +81,12 @@ function AuthRoute({ children }: { children: React.ReactNode }) {
export function App() {
return (
<Routes>
{/* Public routes (no auth required) */}
<Route path="/pricing" element={<PricingPage />} />
<Route path="/activate" element={<ActivatePage />} />
<Route path="/onboarding/pending" element={<OnboardingPendingPage />} />
{/* Auth routes (redirect if already logged in) */}
<Route
path="/login"
element={
@@ -101,6 +111,18 @@ export function App() {
</ProtectedRoute>
}
/>
{/* Onboarding (requires auth but not org selection) */}
<Route
path="/onboarding"
element={
<ProtectedRoute>
<OnboardingPage />
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="/admin"
element={
@@ -111,6 +133,8 @@ export function App() {
>
<Route index element={<AdminPage />} />
</Route>
{/* Main app routes (require auth + org) */}
<Route
path="/*"
element={

View File

@@ -21,6 +21,7 @@ import {
IconCalculator,
IconGitCompare,
IconScale,
IconSettings,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
@@ -102,6 +103,12 @@ const navSections = [
},
],
},
{
label: 'Account',
items: [
{ label: 'Settings', icon: IconSettings, path: '/settings' },
],
},
];
interface SidebarProps {

View File

@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import {
Container, Paper, Title, Text, TextInput, PasswordInput,
Button, Stack, Alert, Center, Loader, Progress, Anchor,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconAlertCircle, IconCheck, IconShieldCheck } from '@tabler/icons-react';
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
import logoSrc from '../../assets/logo.png';
function getPasswordStrength(pw: string): number {
let score = 0;
if (pw.length >= 8) score += 25;
if (pw.length >= 12) score += 15;
if (/[A-Z]/.test(pw)) score += 20;
if (/[a-z]/.test(pw)) score += 10;
if (/[0-9]/.test(pw)) score += 15;
if (/[^A-Za-z0-9]/.test(pw)) score += 15;
return Math.min(score, 100);
}
function strengthColor(s: number): string {
if (s < 40) return 'red';
if (s < 70) return 'orange';
return 'green';
}
export function ActivatePage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const token = searchParams.get('token');
const [validating, setValidating] = useState(true);
const [tokenInfo, setTokenInfo] = useState<any>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const form = useForm({
initialValues: { fullName: '', password: '', confirmPassword: '' },
validate: {
fullName: (v) => (v.trim().length >= 2 ? null : 'Name is required'),
password: (v) => (v.length >= 8 ? null : 'Password must be at least 8 characters'),
confirmPassword: (v, values) => (v === values.password ? null : 'Passwords do not match'),
},
});
useEffect(() => {
if (!token) {
setError('No activation token provided');
setValidating(false);
return;
}
api.get(`/auth/activate?token=${token}`)
.then(({ data }) => {
setTokenInfo(data);
setValidating(false);
})
.catch((err) => {
setError(err.response?.data?.message || 'Invalid or expired activation link');
setValidating(false);
});
}, [token]);
const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
setError('');
try {
const { data } = await api.post('/auth/activate', {
token,
password: values.password,
fullName: values.fullName,
});
setAuth(data.accessToken, data.user, data.organizations);
navigate('/onboarding');
} catch (err: any) {
setError(err.response?.data?.message || 'Activation failed');
} finally {
setLoading(false);
}
};
const passwordStrength = getPasswordStrength(form.values.password);
if (validating) {
return (
<Container size={420} my={80}>
<Center><Loader size="lg" /></Center>
<Text ta="center" mt="md" c="dimmed">Validating activation link...</Text>
</Container>
);
}
if (error && !tokenInfo) {
return (
<Container size={420} my={80}>
<Center>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
</Center>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light" mb="md">
{error}
</Alert>
<Stack>
<Anchor component={Link} to="/login" size="sm" ta="center">
Go to Login
</Anchor>
</Stack>
</Paper>
</Container>
);
}
return (
<Container size={420} my={80}>
<Center>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
</Center>
<Text ta="center" mt={5} c="dimmed" size="sm">
Activate your account for <strong>{tokenInfo?.orgName || 'your organization'}</strong>
</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="Full Name"
placeholder="John Doe"
required
{...form.getInputProps('fullName')}
/>
<div>
<PasswordInput
label="Password"
placeholder="Create a strong password"
required
{...form.getInputProps('password')}
/>
{form.values.password && (
<Progress
value={passwordStrength}
color={strengthColor(passwordStrength)}
size="xs"
mt={4}
/>
)}
</div>
<PasswordInput
label="Confirm Password"
placeholder="Confirm your password"
required
{...form.getInputProps('confirmPassword')}
/>
<Button
type="submit"
fullWidth
loading={loading}
leftSection={<IconShieldCheck size={16} />}
>
Activate Account
</Button>
</Stack>
</form>
</Paper>
</Container>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Center,
Container,
@@ -10,18 +10,41 @@ import {
Anchor,
Stack,
Alert,
Divider,
Group,
PinInput,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconAlertCircle } from '@tabler/icons-react';
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';
@@ -34,20 +57,42 @@ export function LoginPage() {
},
});
// 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);
setAuth(data.accessToken, data.user, data.organizations);
// Platform owner / superadmin with no orgs → admin panel
if (data.user?.isSuperadmin && data.organizations.length === 0) {
navigate('/admin');
} else if (data.organizations.length >= 1) {
// Always go through org selection to ensure correct JWT with orgSchema
navigate('/select-org');
if (data.mfaRequired) {
setMfaToken(data.mfaToken);
setLoginState('mfa');
} else {
navigate('/');
handleLoginSuccess(data);
}
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
@@ -56,6 +101,181 @@ export function LoginPage() {
}
};
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>
@@ -64,9 +284,12 @@ export function LoginPage() {
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))',
} : {}),
...(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>
@@ -102,6 +325,53 @@ export function LoginPage() {
</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>
);

View File

@@ -0,0 +1,241 @@
import { useState, useEffect } from 'react';
import {
Container, Title, Text, Stack, Card, Group, Button, TextInput,
Select, Stepper, ThemeIcon, Progress, Alert, Loader, Center, Anchor,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import {
IconUser, IconBuilding, IconUserPlus, IconListDetails,
IconCheck, IconPlayerPlay, IconConfetti,
} from '@tabler/icons-react';
import { useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
const STEPS = [
{ slug: 'profile', label: 'Complete Your Profile', icon: IconUser, description: 'Set up your name and contact' },
{ slug: 'workspace', label: 'Configure Your HOA', icon: IconBuilding, description: 'Organization name and settings' },
{ slug: 'invite_member', label: 'Invite a Team Member', icon: IconUserPlus, description: 'Add a board member or manager' },
{ slug: 'first_workflow', label: 'Set Up First Account', icon: IconListDetails, description: 'Create your chart of accounts' },
];
export function OnboardingPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const [activeStep, setActiveStep] = useState(0);
const { data: progress, isLoading } = useQuery({
queryKey: ['onboarding-progress'],
queryFn: async () => {
const { data } = await api.get('/onboarding/progress');
return data;
},
});
const markStep = useMutation({
mutationFn: (step: string) => api.patch('/onboarding/progress', { step }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['onboarding-progress'] }),
});
const completedSteps = progress?.completedSteps || [];
const completedCount = completedSteps.length;
const allDone = progress?.completedAt != null;
// Profile form
const profileForm = useForm({
initialValues: {
firstName: user?.firstName || '',
lastName: user?.lastName || '',
phone: '',
},
});
// Workspace form
const workspaceForm = useForm({
initialValues: { orgName: '', address: '', fiscalYearStart: '1' },
});
// Invite form
const inviteForm = useForm({
initialValues: { email: '', role: 'treasurer' },
validate: { email: (v) => (/\S+@\S+/.test(v) ? null : 'Valid email required') },
});
useEffect(() => {
// Auto-advance to first incomplete step
const firstIncomplete = STEPS.findIndex((s) => !completedSteps.includes(s.slug));
if (firstIncomplete >= 0) setActiveStep(firstIncomplete);
}, [completedSteps]);
if (isLoading) {
return <Center h={400}><Loader size="lg" /></Center>;
}
if (allDone) {
return (
<Container size="sm" py={60}>
<Center>
<Stack align="center" gap="lg">
<ThemeIcon size={60} radius="xl" color="green" variant="light">
<IconConfetti size={30} />
</ThemeIcon>
<Title order={2}>You're all set!</Title>
<Text c="dimmed" ta="center">
Your workspace is ready. Let's get to work.
</Text>
<Button size="lg" onClick={() => navigate('/dashboard')}>
Go to Dashboard
</Button>
</Stack>
</Center>
</Container>
);
}
return (
<Container size="md" py={40}>
<Stack gap="lg">
<div>
<Title order={2}>Welcome to HOA LedgerIQ</Title>
<Text c="dimmed" size="sm">Complete these steps to set up your workspace</Text>
</div>
<Progress value={(completedCount / STEPS.length) * 100} size="lg" color="teal" />
<Text size="sm" c="dimmed" ta="center">{completedCount} of {STEPS.length} steps complete</Text>
<Stepper
active={activeStep}
onStepClick={setActiveStep}
orientation="vertical"
size="sm"
>
{/* Step 1: Profile */}
<Stepper.Step
label={STEPS[0].label}
description={STEPS[0].description}
icon={completedSteps.includes('profile') ? <IconCheck size={16} /> : <IconUser size={16} />}
completedIcon={<IconCheck size={16} />}
color={completedSteps.includes('profile') ? 'green' : undefined}
>
<Card withBorder p="lg" mt="sm">
<form onSubmit={profileForm.onSubmit(() => markStep.mutate('profile'))}>
<Stack>
<Group grow>
<TextInput label="First Name" {...profileForm.getInputProps('firstName')} />
<TextInput label="Last Name" {...profileForm.getInputProps('lastName')} />
</Group>
<TextInput label="Phone (optional)" {...profileForm.getInputProps('phone')} />
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
</Stack>
</form>
</Card>
</Stepper.Step>
{/* Step 2: Workspace */}
<Stepper.Step
label={STEPS[1].label}
description={STEPS[1].description}
icon={completedSteps.includes('workspace') ? <IconCheck size={16} /> : <IconBuilding size={16} />}
completedIcon={<IconCheck size={16} />}
color={completedSteps.includes('workspace') ? 'green' : undefined}
>
<Card withBorder p="lg" mt="sm">
<form onSubmit={workspaceForm.onSubmit(() => markStep.mutate('workspace'))}>
<Stack>
<TextInput label="Organization Name" placeholder="Sunset Village HOA" {...workspaceForm.getInputProps('orgName')} />
<TextInput label="Address" placeholder="123 Main St" {...workspaceForm.getInputProps('address')} />
<Select
label="Fiscal Year Start Month"
data={[
{ value: '1', label: 'January' },
{ value: '4', label: 'April' },
{ value: '7', label: 'July' },
{ value: '10', label: 'October' },
]}
{...workspaceForm.getInputProps('fiscalYearStart')}
/>
<Button type="submit" loading={markStep.isPending}>Save & Continue</Button>
</Stack>
</form>
</Card>
</Stepper.Step>
{/* Step 3: Invite */}
<Stepper.Step
label={STEPS[2].label}
description={STEPS[2].description}
icon={completedSteps.includes('invite_member') ? <IconCheck size={16} /> : <IconUserPlus size={16} />}
completedIcon={<IconCheck size={16} />}
color={completedSteps.includes('invite_member') ? 'green' : undefined}
>
<Card withBorder p="lg" mt="sm">
<form onSubmit={inviteForm.onSubmit(() => markStep.mutate('invite_member'))}>
<Stack>
<TextInput label="Email Address" placeholder="teammate@example.com" {...inviteForm.getInputProps('email')} />
<Select
label="Role"
data={[
{ value: 'president', label: 'President' },
{ value: 'treasurer', label: 'Treasurer' },
{ value: 'secretary', label: 'Secretary' },
{ value: 'member_at_large', label: 'Member at Large' },
{ value: 'manager', label: 'Manager' },
{ value: 'viewer', label: 'Viewer' },
]}
{...inviteForm.getInputProps('role')}
/>
<Group>
<Button type="submit" loading={markStep.isPending}>Send Invite & Continue</Button>
<Button variant="subtle" onClick={() => markStep.mutate('invite_member')}>
Skip for now
</Button>
</Group>
</Stack>
</form>
</Card>
</Stepper.Step>
{/* Step 4: First Account */}
<Stepper.Step
label={STEPS[3].label}
description={STEPS[3].description}
icon={completedSteps.includes('first_workflow') ? <IconCheck size={16} /> : <IconListDetails size={16} />}
completedIcon={<IconCheck size={16} />}
color={completedSteps.includes('first_workflow') ? 'green' : undefined}
>
<Card withBorder p="lg" mt="sm">
<Stack>
<Text size="sm">
Your chart of accounts has been pre-configured with standard HOA accounts.
You can review and customize them now, or do it later.
</Text>
<Group>
<Button
leftSection={<IconListDetails size={16} />}
onClick={() => {
markStep.mutate('first_workflow');
navigate('/accounts');
}}
>
Review Accounts
</Button>
<Button variant="subtle" onClick={() => markStep.mutate('first_workflow')}>
Use defaults & Continue
</Button>
</Group>
</Stack>
</Card>
</Stepper.Step>
</Stepper>
<Group justify="center" mt="md">
<Button variant="subtle" color="gray" onClick={() => navigate('/dashboard')}>
Finish Later
</Button>
</Group>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Container, Center, Stack, Loader, Text, Title, Alert, Button } from '@mantine/core';
import { IconCheck, IconAlertCircle } from '@tabler/icons-react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import api from '../../services/api';
export function OnboardingPendingPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const sessionId = searchParams.get('session_id');
const [status, setStatus] = useState<string>('polling');
const [error, setError] = useState('');
useEffect(() => {
if (!sessionId) {
setError('No session ID provided');
return;
}
let cancelled = false;
const poll = async () => {
try {
const { data } = await api.get(`/billing/status?session_id=${sessionId}`);
if (cancelled) return;
if (data.status === 'active') {
setStatus('complete');
// Redirect to login page — user will get activation email
setTimeout(() => navigate('/login'), 3000);
} else if (data.status === 'not_configured') {
setError('Payment system is not configured. Please contact support.');
} else {
// Still provisioning — poll again
setTimeout(poll, 3000);
}
} catch (err: any) {
if (!cancelled) {
setError(err.response?.data?.message || 'Failed to check status');
}
}
};
poll();
return () => { cancelled = true; };
}, [sessionId, navigate]);
return (
<Container size="sm" py={80}>
<Center>
<Stack align="center" gap="lg">
{error ? (
<>
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
<Button variant="light" onClick={() => navigate('/pricing')}>
Back to Pricing
</Button>
</>
) : status === 'complete' ? (
<>
<IconCheck size={48} color="var(--mantine-color-green-6)" />
<Title order={2}>Your account is ready!</Title>
<Text c="dimmed" ta="center">
Check your email for an activation link to set your password and get started.
</Text>
<Text size="sm" c="dimmed">Redirecting to login...</Text>
</>
) : (
<>
<Loader size="xl" />
<Title order={2}>Setting up your account...</Title>
<Text c="dimmed" ta="center" maw={400}>
We're creating your HOA workspace. This usually takes just a few seconds.
</Text>
</>
)}
</Stack>
</Center>
</Container>
);
}

View File

@@ -0,0 +1,211 @@
import { useState } from 'react';
import {
Container, Title, Text, SimpleGrid, Card, Stack, Group, Badge,
Button, List, ThemeIcon, TextInput, Center, Alert,
} from '@mantine/core';
import { IconCheck, IconX, IconRocket, IconStar, IconCrown, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import logoSrc from '../../assets/logo.png';
const plans = [
{
id: 'starter',
name: 'Starter',
price: '$29',
period: '/month',
description: 'For small communities getting started',
icon: IconRocket,
color: 'blue',
features: [
{ text: 'Up to 50 units', included: true },
{ text: 'Chart of Accounts', included: true },
{ text: 'Assessment Tracking', included: true },
{ text: 'Basic Reports', included: true },
{ text: 'Board Planning', included: false },
{ text: 'AI Investment Advisor', included: false },
],
},
{
id: 'professional',
name: 'Professional',
price: '$79',
period: '/month',
description: 'For growing HOAs that need full features',
icon: IconStar,
color: 'violet',
popular: true,
features: [
{ text: 'Up to 200 units', included: true },
{ text: 'Everything in Starter', included: true },
{ text: 'Board Planning & Scenarios', included: true },
{ text: 'AI Investment Advisor', included: true },
{ text: 'Advanced Reports', included: true },
{ text: 'Priority Support', included: false },
],
},
{
id: 'enterprise',
name: 'Enterprise',
price: '$199',
period: '/month',
description: 'For large communities and management firms',
icon: IconCrown,
color: 'orange',
features: [
{ text: 'Unlimited units', included: true },
{ text: 'Everything in Professional', included: true },
{ text: 'Priority Support', included: true },
{ text: 'Custom Integrations', included: true },
{ text: 'Dedicated Account Manager', included: true },
{ text: 'SLA Guarantee', included: true },
],
},
];
export function PricingPage() {
const navigate = useNavigate();
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState('');
const [email, setEmail] = useState('');
const [businessName, setBusinessName] = useState('');
const handleSelectPlan = async (planId: string) => {
setLoading(planId);
setError('');
try {
const { data } = await api.post('/billing/create-checkout-session', {
planId,
email: email || undefined,
businessName: businessName || undefined,
});
if (data.url) {
window.location.href = data.url;
} else {
setError('Unable to create checkout session');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to start checkout');
} finally {
setLoading(null);
}
};
return (
<Container size="lg" py={60}>
<Stack align="center" mb={40}>
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 50 }} />
<Title order={1} ta="center">
Simple, transparent pricing
</Title>
<Text size="lg" c="dimmed" ta="center" maw={500}>
Choose the plan that fits your community. All plans include a 14-day free trial.
</Text>
</Stack>
{/* Optional pre-capture fields */}
<Center mb="xl">
<Group>
<TextInput
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
style={{ width: 220 }}
/>
<TextInput
placeholder="HOA / Business name"
value={businessName}
onChange={(e) => setBusinessName(e.currentTarget.value)}
style={{ width: 220 }}
/>
</Group>
</Center>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" mb="lg" variant="light">
{error}
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{plans.map((plan) => (
<Card
key={plan.id}
withBorder
shadow={plan.popular ? 'lg' : 'sm'}
radius="md"
p="xl"
style={plan.popular ? {
border: '2px solid var(--mantine-color-violet-5)',
position: 'relative',
} : undefined}
>
{plan.popular && (
<Badge
color="violet"
variant="filled"
style={{ position: 'absolute', top: -10, right: 20 }}
>
Most Popular
</Badge>
)}
<Stack gap="md">
<Group>
<ThemeIcon size="lg" color={plan.color} variant="light" radius="md">
<plan.icon size={20} />
</ThemeIcon>
<div>
<Text fw={700} size="lg">{plan.name}</Text>
<Text size="xs" c="dimmed">{plan.description}</Text>
</div>
</Group>
<Group align="baseline" gap={4}>
<Text fw={800} size="xl" ff="monospace" style={{ fontSize: 36 }}>
{plan.price}
</Text>
<Text size="sm" c="dimmed">{plan.period}</Text>
</Group>
<List spacing="xs" size="sm" center>
{plan.features.map((f, i) => (
<List.Item
key={i}
icon={
<ThemeIcon
size={20}
radius="xl"
color={f.included ? 'teal' : 'gray'}
variant={f.included ? 'filled' : 'light'}
>
{f.included ? <IconCheck size={12} /> : <IconX size={12} />}
</ThemeIcon>
}
>
<Text c={f.included ? undefined : 'dimmed'}>{f.text}</Text>
</List.Item>
))}
</List>
<Button
fullWidth
size="md"
color={plan.color}
variant={plan.popular ? 'filled' : 'light'}
loading={loading === plan.id}
onClick={() => handleSelectPlan(plan.id)}
>
Get Started
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
<Text ta="center" size="sm" c="dimmed" mt="xl">
All plans include a 14-day free trial. No credit card required to start.
</Text>
</Container>
);
}

View File

@@ -0,0 +1,97 @@
import {
Card, Title, Text, Stack, Group, Button, Badge, Alert,
} from '@mantine/core';
import { IconBrandGoogle, IconBrandAzure, IconLink, IconLinkOff, IconAlertCircle } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
export function LinkedAccounts() {
const queryClient = useQueryClient();
const { data: providers } = useQuery({
queryKey: ['sso-providers'],
queryFn: async () => {
const { data } = await api.get('/auth/sso/providers');
return data;
},
});
const { data: profile } = useQuery({
queryKey: ['auth-profile'],
queryFn: async () => {
const { data } = await api.get('/auth/profile');
return data;
},
});
const unlinkMutation = useMutation({
mutationFn: (provider: string) => api.delete(`/auth/sso/unlink/${provider}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth-profile'] });
notifications.show({ message: 'Account unlinked', color: 'orange' });
},
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to unlink', color: 'red' }),
});
const noProviders = !providers?.google && !providers?.azure;
return (
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<div>
<Title order={4}>Linked Accounts</Title>
<Text size="sm" c="dimmed">Connect third-party accounts for single sign-on</Text>
</div>
</Group>
{noProviders && (
<Alert color="gray" variant="light" icon={<IconAlertCircle size={16} />}>
No SSO providers are configured. Contact your administrator to enable Google or Microsoft SSO.
</Alert>
)}
<Stack gap="md">
{providers?.google && (
<Group justify="space-between" p="sm" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
<Group>
<IconBrandGoogle size={24} color="#4285F4" />
<div>
<Text fw={500}>Google</Text>
<Text size="xs" c="dimmed">Sign in with your Google account</Text>
</div>
</Group>
<Button
variant="light"
size="sm"
leftSection={<IconLink size={14} />}
onClick={() => window.location.href = '/api/auth/google'}
>
Connect
</Button>
</Group>
)}
{providers?.azure && (
<Group justify="space-between" p="sm" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 8 }}>
<Group>
<IconBrandAzure size={24} color="#0078D4" />
<div>
<Text fw={500}>Microsoft</Text>
<Text size="xs" c="dimmed">Sign in with your Microsoft account</Text>
</div>
</Group>
<Button
variant="light"
size="sm"
leftSection={<IconLink size={14} />}
onClick={() => window.location.href = '/api/auth/azure'}
>
Connect
</Button>
</Group>
)}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react';
import {
Card, Title, Text, Stack, Group, Button, TextInput,
PasswordInput, Alert, Code, SimpleGrid, Badge, Image,
} from '@mantine/core';
import { IconShieldCheck, IconShieldOff, IconAlertCircle } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
export function MfaSettings() {
const queryClient = useQueryClient();
const [setupData, setSetupData] = useState<any>(null);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [verifyCode, setVerifyCode] = useState('');
const [disablePassword, setDisablePassword] = useState('');
const [showDisable, setShowDisable] = useState(false);
const { data: mfaStatus, isLoading } = useQuery({
queryKey: ['mfa-status'],
queryFn: async () => {
const { data } = await api.get('/auth/mfa/status');
return data;
},
});
const setupMutation = useMutation({
mutationFn: () => api.post('/auth/mfa/setup'),
onSuccess: ({ data }) => setSetupData(data),
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Setup failed', color: 'red' }),
});
const enableMutation = useMutation({
mutationFn: (token: string) => api.post('/auth/mfa/enable', { token }),
onSuccess: ({ data }) => {
setRecoveryCodes(data.recoveryCodes);
setSetupData(null);
setVerifyCode('');
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
notifications.show({ message: 'MFA enabled successfully', color: 'green' });
},
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid code', color: 'red' }),
});
const disableMutation = useMutation({
mutationFn: (password: string) => api.post('/auth/mfa/disable', { password }),
onSuccess: () => {
setShowDisable(false);
setDisablePassword('');
queryClient.invalidateQueries({ queryKey: ['mfa-status'] });
notifications.show({ message: 'MFA disabled', color: 'orange' });
},
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Invalid password', color: 'red' }),
});
if (isLoading) return null;
return (
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<div>
<Title order={4}>Two-Factor Authentication (MFA)</Title>
<Text size="sm" c="dimmed">Add an extra layer of security to your account</Text>
</div>
<Badge color={mfaStatus?.enabled ? 'green' : 'gray'} variant="light" size="lg">
{mfaStatus?.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</Group>
{/* Recovery codes display (shown once after enable) */}
{recoveryCodes && (
<Alert color="orange" variant="light" mb="md" icon={<IconAlertCircle size={16} />} title="Save your recovery codes">
<Text size="sm" mb="sm">
These codes can be used to access your account if you lose your authenticator. Save them securely they will not be shown again.
</Text>
<SimpleGrid cols={2} spacing="xs">
{recoveryCodes.map((code, i) => (
<Code key={i} block>{code}</Code>
))}
</SimpleGrid>
<Button variant="subtle" size="xs" mt="sm" onClick={() => setRecoveryCodes(null)}>
I've saved my codes
</Button>
</Alert>
)}
{!mfaStatus?.enabled && !setupData && (
<Button
leftSection={<IconShieldCheck size={16} />}
onClick={() => setupMutation.mutate()}
loading={setupMutation.isPending}
>
Set Up MFA
</Button>
)}
{/* QR Code Setup */}
{setupData && (
<Stack>
<Text size="sm">Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.):</Text>
<Group justify="center">
<Image src={setupData.qrDataUrl} w={200} h={200} />
</Group>
<Text size="xs" c="dimmed" ta="center">
Manual entry key: <Code>{setupData.secret}</Code>
</Text>
<TextInput
label="Verification Code"
placeholder="Enter 6-digit code"
value={verifyCode}
onChange={(e) => setVerifyCode(e.currentTarget.value)}
maxLength={6}
/>
<Group>
<Button
onClick={() => enableMutation.mutate(verifyCode)}
loading={enableMutation.isPending}
disabled={verifyCode.length < 6}
>
Verify & Enable
</Button>
<Button variant="subtle" onClick={() => setSetupData(null)}>Cancel</Button>
</Group>
</Stack>
)}
{/* Disable MFA */}
{mfaStatus?.enabled && !showDisable && (
<Button
variant="subtle"
color="red"
leftSection={<IconShieldOff size={16} />}
onClick={() => setShowDisable(true)}
>
Disable MFA
</Button>
)}
{showDisable && (
<Stack mt="md">
<Alert color="red" variant="light">
Disabling MFA will make your account less secure. Enter your password to confirm.
</Alert>
<PasswordInput
label="Current Password"
value={disablePassword}
onChange={(e) => setDisablePassword(e.currentTarget.value)}
/>
<Group>
<Button color="red" onClick={() => disableMutation.mutate(disablePassword)} loading={disableMutation.isPending}>
Disable MFA
</Button>
<Button variant="subtle" onClick={() => setShowDisable(false)}>Cancel</Button>
</Group>
</Stack>
)}
</Card>
);
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react';
import {
Card, Title, Text, Stack, Group, Button, TextInput,
Table, Badge, ActionIcon, Tooltip, Alert,
} from '@mantine/core';
import { IconFingerprint, IconTrash, IconPlus, IconAlertCircle } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { startRegistration } from '@simplewebauthn/browser';
import api from '../../services/api';
export function PasskeySettings() {
const queryClient = useQueryClient();
const [deviceName, setDeviceName] = useState('');
const [registering, setRegistering] = useState(false);
const { data: passkeys = [], isLoading } = useQuery({
queryKey: ['passkeys'],
queryFn: async () => {
const { data } = await api.get('/auth/passkeys');
return data;
},
});
const removeMutation = useMutation({
mutationFn: (id: string) => api.delete(`/auth/passkeys/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
notifications.show({ message: 'Passkey removed', color: 'orange' });
},
onError: (err: any) => notifications.show({ message: err.response?.data?.message || 'Failed to remove', color: 'red' }),
});
const handleRegister = async () => {
setRegistering(true);
try {
// 1. Get registration options from server
const { data: options } = await api.post('/auth/passkeys/register-options');
// 2. Create credential via browser WebAuthn API
const credential = await startRegistration({ optionsJSON: options });
// 3. Send attestation to server for verification
await api.post('/auth/passkeys/register', {
response: credential,
deviceName: deviceName || 'My Passkey',
});
queryClient.invalidateQueries({ queryKey: ['passkeys'] });
setDeviceName('');
notifications.show({ message: 'Passkey registered successfully', color: 'green' });
} catch (err: any) {
if (err.name === 'NotAllowedError') {
notifications.show({ message: 'Registration was cancelled', color: 'yellow' });
} else {
notifications.show({ message: err.response?.data?.message || err.message || 'Registration failed', color: 'red' });
}
} finally {
setRegistering(false);
}
};
const webauthnSupported = typeof window !== 'undefined' && !!window.PublicKeyCredential;
return (
<Card withBorder p="lg">
<Group justify="space-between" mb="md">
<div>
<Title order={4}>Passkeys</Title>
<Text size="sm" c="dimmed">Sign in with your fingerprint, face, or security key</Text>
</div>
<Badge color={passkeys.length > 0 ? 'green' : 'gray'} variant="light" size="lg">
{passkeys.length} registered
</Badge>
</Group>
{!webauthnSupported && (
<Alert color="yellow" variant="light" icon={<IconAlertCircle size={16} />} mb="md">
Your browser doesn't support WebAuthn passkeys.
</Alert>
)}
{passkeys.length > 0 && (
<Table striped mb="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Device</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Last Used</Table.Th>
<Table.Th w={60}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{passkeys.map((pk: any) => (
<Table.Tr key={pk.id}>
<Table.Td>
<Group gap="xs">
<IconFingerprint size={16} />
<Text size="sm" fw={500}>{pk.device_name || 'Passkey'}</Text>
</Group>
</Table.Td>
<Table.Td><Text size="sm">{new Date(pk.created_at).toLocaleDateString()}</Text></Table.Td>
<Table.Td>
<Text size="sm" c={pk.last_used_at ? undefined : 'dimmed'}>
{pk.last_used_at ? new Date(pk.last_used_at).toLocaleDateString() : 'Never'}
</Text>
</Table.Td>
<Table.Td>
<Tooltip label="Remove">
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(pk.id)}>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
{webauthnSupported && (
<Group>
<TextInput
placeholder="Device name (e.g., MacBook Pro)"
value={deviceName}
onChange={(e) => setDeviceName(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Button
leftSection={<IconPlus size={16} />}
onClick={handleRegister}
loading={registering}
>
Register Passkey
</Button>
</Group>
)}
</Card>
);
}

View File

@@ -1,14 +1,34 @@
import { useState } from 'react';
import {
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
Tabs, Button,
} from '@mantine/core';
import {
IconBuilding, IconUser, IconUsers, IconSettings, IconShieldLock,
IconCalendar,
IconBuilding, IconUser, IconSettings, IconShieldLock,
IconFingerprint, IconLink, IconLogout,
} from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { useAuthStore } from '../../stores/authStore';
import { MfaSettings } from './MfaSettings';
import { PasskeySettings } from './PasskeySettings';
import { LinkedAccounts } from './LinkedAccounts';
import api from '../../services/api';
export function SettingsPage() {
const { user, currentOrg } = useAuthStore();
const [loggingOutAll, setLoggingOutAll] = useState(false);
const handleLogoutEverywhere = async () => {
setLoggingOutAll(true);
try {
await api.post('/auth/logout-everywhere');
notifications.show({ message: 'All other sessions have been logged out', color: 'green' });
} catch {
notifications.show({ message: 'Failed to log out other sessions', color: 'red' });
} finally {
setLoggingOutAll(false);
}
};
return (
<Stack>
@@ -68,33 +88,6 @@ export function SettingsPage() {
</Stack>
</Card>
{/* Security */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="red" variant="light" size={40} radius="md">
<IconShieldLock size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Security</Text>
<Text c="dimmed" size="sm">Authentication and access</Text>
</div>
</Group>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Authentication</Text>
<Badge color="green" variant="light">Active Session</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Two-Factor Auth</Text>
<Badge color="gray" variant="light">Not Configured</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">OAuth Providers</Text>
<Badge color="gray" variant="light">None Linked</Badge>
</Group>
</Stack>
</Card>
{/* System Info */}
<Card withBorder padding="lg">
<Group mb="md">
@@ -113,7 +106,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.03.10</Badge>
<Badge variant="light">2026.03.17</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>
@@ -121,7 +114,71 @@ export function SettingsPage() {
</Group>
</Stack>
</Card>
{/* Sessions */}
<Card withBorder padding="lg">
<Group mb="md">
<ThemeIcon color="orange" variant="light" size={40} radius="md">
<IconLogout size={24} />
</ThemeIcon>
<div>
<Text fw={600} size="lg">Sessions</Text>
<Text c="dimmed" size="sm">Manage active sessions</Text>
</div>
</Group>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Current Session</Text>
<Badge color="green" variant="light">Active</Badge>
</Group>
<Button
variant="light"
color="orange"
size="sm"
leftSection={<IconLogout size={16} />}
onClick={handleLogoutEverywhere}
loading={loggingOutAll}
mt="xs"
>
Log Out All Other Sessions
</Button>
</Stack>
</Card>
</SimpleGrid>
<Divider my="md" />
{/* Security Settings */}
<div>
<Title order={3} mb="sm">Security</Title>
<Text c="dimmed" size="sm" mb="md">Manage authentication methods and security settings</Text>
</div>
<Tabs defaultValue="mfa">
<Tabs.List>
<Tabs.Tab value="mfa" leftSection={<IconShieldLock size={16} />}>
Two-Factor Auth
</Tabs.Tab>
<Tabs.Tab value="passkeys" leftSection={<IconFingerprint size={16} />}>
Passkeys
</Tabs.Tab>
<Tabs.Tab value="linked" leftSection={<IconLink size={16} />}>
Linked Accounts
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="mfa" pt="md">
<MfaSettings />
</Tabs.Panel>
<Tabs.Panel value="passkeys" pt="md">
<PasskeySettings />
</Tabs.Panel>
<Tabs.Panel value="linked" pt="md">
<LinkedAccounts />
</Tabs.Panel>
</Tabs>
</Stack>
);
}

View File

@@ -1,9 +1,10 @@
import axios from 'axios';
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { useAuthStore } from '../stores/authStore';
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // Send httpOnly cookies for refresh token
});
api.interceptors.request.use((config) => {
@@ -14,23 +15,89 @@ api.interceptors.request.use((config) => {
return config;
});
// ─── Silent Refresh Logic ─────────────────────────────────────────
let isRefreshing = false;
let pendingQueue: Array<{
resolve: (token: string) => void;
reject: (err: any) => void;
}> = [];
function processPendingQueue(error: any, token: string | null) {
pendingQueue.forEach((p) => {
if (error) {
p.reject(error);
} else {
p.resolve(token!);
}
});
pendingQueue = [];
}
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// If 401 and we haven't retried yet, try refreshing the token
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!originalRequest.url?.includes('/auth/refresh') &&
!originalRequest.url?.includes('/auth/login')
) {
originalRequest._retry = true;
if (isRefreshing) {
// Another request is already refreshing — queue this one
return new Promise((resolve, reject) => {
pendingQueue.push({
resolve: (token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
},
reject: (err: any) => reject(err),
});
});
}
isRefreshing = true;
try {
const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
const newToken = data.accessToken;
useAuthStore.getState().setToken(newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
processPendingQueue(null, newToken);
return api(originalRequest);
} catch (refreshError) {
processPendingQueue(refreshError, null);
useAuthStore.getState().logout();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// Non-retryable 401 (e.g. refresh failed, login failed)
if (error.response?.status === 401 && originalRequest?.url?.includes('/auth/refresh')) {
useAuthStore.getState().logout();
window.location.href = '/login';
}
// Handle org suspended/archived — redirect to org selection
const responseData = error.response?.data as any;
if (
error.response?.status === 403 &&
typeof error.response?.data?.message === 'string' &&
error.response.data.message.includes('has been')
typeof responseData?.message === 'string' &&
responseData.message.includes('has been')
) {
const store = useAuthStore.getState();
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
window.location.href = '/select-org';
}
return Promise.reject(error);
},
);

View File

@@ -33,6 +33,7 @@ interface AuthState {
currentOrg: Organization | null;
impersonationOriginal: ImpersonationOriginal | null;
setAuth: (token: string, user: User, organizations: Organization[]) => void;
setToken: (token: string) => void;
setCurrentOrg: (org: Organization, token?: string) => void;
setUserIntroSeen: () => void;
setOrgSettings: (settings: Record<string, any>) => void;
@@ -60,6 +61,7 @@ export const useAuthStore = create<AuthState>()(
// Don't auto-select org — force user through SelectOrgPage
currentOrg: null,
}),
setToken: (token) => set({ token }),
setCurrentOrg: (org, token) =>
set((state) => ({
currentOrg: org,
@@ -102,14 +104,17 @@ export const useAuthStore = create<AuthState>()(
});
}
},
logout: () =>
logout: () => {
// Fire-and-forget server-side logout to revoke refresh token cookie
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
set({
token: null,
user: null,
organizations: [],
currentOrg: null,
impersonationOriginal: null,
}),
});
},
}),
{
name: 'ledgeriq-auth',