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>
141 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|