Files
HOA_Financial_Platform/frontend/src/pages/settings/PasskeySettings.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

141 lines
4.9 KiB
TypeScript

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