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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user