Files
HOA_Financial_Platform/frontend/src/pages/settings/LinkedAccounts.tsx
olsch01 dfcd172ef3 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>
2026-03-16 21:12:35 -04:00

98 lines
3.2 KiB
TypeScript

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>
);
}