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:
97
frontend/src/pages/settings/LinkedAccounts.tsx
Normal file
97
frontend/src/pages/settings/LinkedAccounts.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
140
frontend/src/pages/settings/PasskeySettings.tsx
Normal file
140
frontend/src/pages/settings/PasskeySettings.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
|
||||
Tabs, Button,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBuilding, IconUser, IconUsers, IconSettings, IconShieldLock,
|
||||
IconCalendar,
|
||||
IconBuilding, IconUser, IconSettings, IconShieldLock,
|
||||
IconFingerprint, IconLink, IconLogout,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { MfaSettings } from './MfaSettings';
|
||||
import { PasskeySettings } from './PasskeySettings';
|
||||
import { LinkedAccounts } from './LinkedAccounts';
|
||||
import api from '../../services/api';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user, currentOrg } = useAuthStore();
|
||||
const [loggingOutAll, setLoggingOutAll] = useState(false);
|
||||
|
||||
const handleLogoutEverywhere = async () => {
|
||||
setLoggingOutAll(true);
|
||||
try {
|
||||
await api.post('/auth/logout-everywhere');
|
||||
notifications.show({ message: 'All other sessions have been logged out', color: 'green' });
|
||||
} catch {
|
||||
notifications.show({ message: 'Failed to log out other sessions', color: 'red' });
|
||||
} finally {
|
||||
setLoggingOutAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -68,33 +88,6 @@ export function SettingsPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
<ThemeIcon color="red" variant="light" size={40} radius="md">
|
||||
<IconShieldLock size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="lg">Security</Text>
|
||||
<Text c="dimmed" size="sm">Authentication and access</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Authentication</Text>
|
||||
<Badge color="green" variant="light">Active Session</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Two-Factor Auth</Text>
|
||||
<Badge color="gray" variant="light">Not Configured</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">OAuth Providers</Text>
|
||||
<Badge color="gray" variant="light">None Linked</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* System Info */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
@@ -113,7 +106,7 @@ export function SettingsPage() {
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Version</Text>
|
||||
<Badge variant="light">2026.03.10</Badge>
|
||||
<Badge variant="light">2026.03.17</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">API</Text>
|
||||
@@ -121,7 +114,71 @@ export function SettingsPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Sessions */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
<ThemeIcon color="orange" variant="light" size={40} radius="md">
|
||||
<IconLogout size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="lg">Sessions</Text>
|
||||
<Text c="dimmed" size="sm">Manage active sessions</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Current Session</Text>
|
||||
<Badge color="green" variant="light">Active</Badge>
|
||||
</Group>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="sm"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={handleLogoutEverywhere}
|
||||
loading={loggingOutAll}
|
||||
mt="xs"
|
||||
>
|
||||
Log Out All Other Sessions
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
{/* Security Settings */}
|
||||
<div>
|
||||
<Title order={3} mb="sm">Security</Title>
|
||||
<Text c="dimmed" size="sm" mb="md">Manage authentication methods and security settings</Text>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="mfa">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="mfa" leftSection={<IconShieldLock size={16} />}>
|
||||
Two-Factor Auth
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="passkeys" leftSection={<IconFingerprint size={16} />}>
|
||||
Passkeys
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="linked" leftSection={<IconLink size={16} />}>
|
||||
Linked Accounts
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="mfa" pt="md">
|
||||
<MfaSettings />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="passkeys" pt="md">
|
||||
<PasskeySettings />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="linked" pt="md">
|
||||
<LinkedAccounts />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user