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>
160 lines
5.8 KiB
TypeScript
160 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
}
|