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:
@@ -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={
|
||||
|
||||
@@ -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 {
|
||||
|
||||
179
frontend/src/pages/auth/ActivatePage.tsx
Normal file
179
frontend/src/pages/auth/ActivatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
241
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
241
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
frontend/src/pages/onboarding/OnboardingPendingPage.tsx
Normal file
82
frontend/src/pages/onboarding/OnboardingPendingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
frontend/src/pages/pricing/PricingPage.tsx
Normal file
211
frontend/src/pages/pricing/PricingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/pages/settings/LinkedAccounts.tsx
Normal file
97
frontend/src/pages/settings/LinkedAccounts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
frontend/src/pages/settings/MfaSettings.tsx
Normal file
159
frontend/src/pages/settings/MfaSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
frontend/src/pages/settings/PasskeySettings.tsx
Normal file
140
frontend/src/pages/settings/PasskeySettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user