Private
Public Access
1
0

Phase 4: Exhaustive analysis fixes, security hardening, and code quality improvements

This commit is contained in:
2026-05-15 22:10:05 +00:00
parent 4593458c5a
commit 7b067b2813
4 changed files with 499 additions and 98 deletions

View File

@ -121,6 +121,25 @@ export const authApi = {
verifyMfa: (secretBase32: string, code: string) =>
apiClient.post('/auth/mfa/verify', { secret_base32: secretBase32, code }),
// WebAuthn MFA stubs
webauthnAuthenticateStart: () =>
apiClient.post('/auth/mfa/webauthn/authenticate/start'),
webauthnAuthenticateComplete: (challengeKey: string, serializedAssertion: unknown) =>
apiClient.post('/auth/mfa/webauthn/authenticate/complete', { challenge_key: challengeKey, serialized_assertion: serializedAssertion }),
webauthnListCredentials: () =>
apiClient.get('/auth/mfa/webauthn/credentials'),
webauthnRegisterStart: (keyName?: string) =>
apiClient.post('/auth/mfa/webauthn/register/start', { key_name: keyName }),
webauthnRegisterComplete: (challengeKey: string, serializedCredential: unknown, keyName?: string) =>
apiClient.post('/auth/mfa/webauthn/register/complete', { challenge_key: challengeKey, serialized_credential: serializedCredential, key_name: keyName }),
webauthnDeleteCredential: (id: string) =>
apiClient.delete(`/auth/mfa/webauthn/credentials/${id}`),
}
// ── Fleet API functions ──────────────────────────────────────────────────────

View File

@ -1,8 +1,9 @@
import { useEffect, useState, useCallback } from 'react'
import {
Box, Button, Chip, CircularProgress, Container, IconButton,
Paper, Table, TableBody, TableCell, TableContainer, TableHead,
TableRow, TextField, Toolbar, Tooltip, Typography,
Box, Button, Chip, CircularProgress, Container, Dialog, DialogTitle,
DialogContent, DialogActions, IconButton, Paper, Snackbar, Alert,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
TablePagination, TextField, Toolbar, Tooltip, Typography,
} from '@mui/material'
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material'
import { useNavigate } from 'react-router-dom'
@ -19,19 +20,24 @@ export default function HostsPage() {
const canWrite = user?.role === 'admin' || user?.role === 'operator'
const [hosts, setHosts] = useState<Host[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(25)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [refreshing, setRefreshing] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Host | null>(null)
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' })
const load = useCallback(async () => {
setLoading(true)
try {
const res = await apiClient.get('/hosts', { params: { limit: 100 } })
const offset = page * rowsPerPage
const res = await apiClient.get('/hosts', { params: { limit: rowsPerPage, offset } })
setHosts(res.data.hosts)
setTotal(res.data.total)
} catch { /* handled by interceptor */ }
finally { setLoading(false) }
}, [])
}, [page, rowsPerPage])
const handleRefresh = async (e: React.MouseEvent, hostId: string) => {
e.stopPropagation()
@ -44,13 +50,35 @@ export default function HostsPage() {
}
}
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const handleDelete = async () => {
if (!deleteTarget) return
try {
await hostsApi.delete(deleteTarget.id)
setSnackbar({ open: true, message: `Host "${deleteTarget.display_name || deleteTarget.fqdn}" deleted`, severity: 'success' })
load()
} catch {
setSnackbar({ open: true, message: `Failed to delete host "${deleteTarget.display_name || deleteTarget.fqdn}"`, severity: 'error' })
} finally {
setDeleteTarget(null)
}
}
useEffect(() => { load() }, [load])
const filtered = hosts.filter(h =>
h.fqdn.toLowerCase().includes(search.toLowerCase()) ||
h.display_name.toLowerCase().includes(search.toLowerCase())
)
const handleChangePage = (_event: React.MouseEvent<HTMLButtonElement> | null, newPage: number) => {
setPage(newPage)
}
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10))
setPage(0)
}
return (
<Container maxWidth="xl" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 2 }}>
@ -60,7 +88,7 @@ export default function HostsPage() {
<Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip>
{canWrite && <Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>}
</Toolbar>
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
{loading ? <Box display="flex" justifyContent="center" mt="4"><CircularProgress /></Box> : (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
@ -106,7 +134,7 @@ export default function HostsPage() {
: <RefreshIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Delete"><IconButton size="small" color="error">
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteTarget(h) }}>
<DeleteIcon fontSize="small" />
</IconButton></Tooltip>
</TableCell>}
@ -114,11 +142,33 @@ export default function HostsPage() {
))}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
/>
</TableContainer>
)}
<Typography variant="caption" color="text.secondary" mt={1} display="block">
Showing {filtered.length} of {total} hosts
</Typography>
<Dialog open={deleteTarget !== null} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete host &ldquo;{deleteTarget?.display_name || deleteTarget?.fqdn}&rdquo;?
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" variant="contained">Delete</Button>
</DialogActions>
</Dialog>
<Snackbar open={snackbar.open} autoHideDuration={4000} onClose={() => setSnackbar(s => ({ ...s, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
<Alert severity={snackbar.severity} onClose={() => setSnackbar(s => ({ ...s, open: false }))}
sx={{ width: '100%' }}>{snackbar.message}</Alert>
</Snackbar>
</Container>
)
}

View File

@ -15,6 +15,26 @@ import { authApi, ssoConfigApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import type { User } from '../types'
// ── WebAuthn utility functions ──────────────────────────────────────────────
function arrayBufferToBase64url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function base64urlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const binary = atob(base64 + padding)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function getErrorMessage(err: unknown): string {
if (err instanceof Error && err.message === 'Network Error') {
return 'Unable to connect to the server. Please check your network connection and try again.'
@ -25,6 +45,7 @@ function getErrorMessage(err: unknown): string {
const msg = axiosErr.response?.data?.error?.message
if (status === 429) return 'Too many login attempts. Please wait a moment and try again.'
if (code === 'mfa_required') return 'MFA_REQUIRED'
if (code === 'mfa_required_webauthn') return 'MFA_REQUIRED_WEBAUTHN'
if (code === 'password_reset_required') return 'PASSWORD_RESET_REQUIRED'
if (code === 'account_locked') return 'ACCOUNT_LOCKED'
if (code === 'account_disabled') return 'This account has been disabled. Contact your administrator.'
@ -58,6 +79,8 @@ export default function LoginPage() {
const [totpCode, setTotpCode] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [needsMfa, setNeedsMfa] = useState(false)
const [needsWebAuthn, setNeedsWebAuthn] = useState(false)
const [webAuthnLoading, setWebAuthnLoading] = useState(false)
const [forcePasswordReset, setForcePasswordReset] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -98,6 +121,9 @@ export default function LoginPage() {
if (message === 'MFA_REQUIRED') {
setNeedsMfa(true)
setError('Please enter your MFA code.')
} else if (message === 'MFA_REQUIRED_WEBAUTHN') {
setNeedsWebAuthn(true)
setError('Please authenticate with your security key.')
} else if (message === 'PASSWORD_RESET_REQUIRED') {
setForcePasswordReset(true)
setError('You must change your password before logging in.')
@ -111,6 +137,68 @@ export default function LoginPage() {
}
}
const handleWebAuthnLogin = async () => {
setWebAuthnLoading(true)
setError(null)
try {
const startRes = await authApi.webauthnAuthenticateStart()
const { challenge_key, assertion_options } = startRes.data
const publicKey = assertion_options.publicKey
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
...publicKey,
challenge: base64urlToArrayBuffer(publicKey.challenge),
allowCredentials: publicKey.allowCredentials?.map((c: { type: string; id: string }) => ({
...c,
id: base64urlToArrayBuffer(c.id),
})),
}
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
}) as PublicKeyCredential | null
if (!assertion) {
setError('Security key authentication was cancelled.')
return
}
const response = assertion.response as AuthenticatorAssertionResponse
const serializedAssertion = {
id: assertion.id,
rawId: arrayBufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: arrayBufferToBase64url(response.authenticatorData),
clientDataJSON: arrayBufferToBase64url(response.clientDataJSON),
signature: arrayBufferToBase64url(response.signature),
userHandle: response.userHandle ? arrayBufferToBase64url(response.userHandle) : null,
},
}
const completeRes = await authApi.webauthnAuthenticateComplete(challenge_key, serializedAssertion)
if (completeRes.data.access_token && completeRes.data.refresh_token) {
const { access_token, refresh_token, user } = completeRes.data
setTokens(access_token, refresh_token)
setUser(user as User)
navigate('/dashboard', { replace: true })
} else {
setError('WebAuthn authentication succeeded. Please try logging in again.')
setNeedsWebAuthn(false)
}
} catch (err: unknown) {
const error = err as { name?: string; response?: { data?: { error?: { message?: string } } }; message?: string };
if (error.name === 'NotAllowedError') {
setError('Security key authentication was cancelled or timed out.');
} else {
const msg = error.response?.data?.error?.message || error.message || 'Authentication failed.';
setError(`Security key authentication failed: ${msg}`);
}
} finally {
setWebAuthnLoading(false)
}
}
const handleForceChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
if (!pwValid || pwMismatch) return
@ -196,11 +284,29 @@ export default function LoginPage() {
{needsMfa && (
<TextField fullWidth margin="normal" label="MFA Code" inputMode="numeric" inputProps={{ maxLength: 6, pattern: '[0-9]*' }} value={totpCode} onChange={(e) => setTotpCode(e.target.value)} disabled={loading} required autoFocus helperText="Enter the 6-digit code from your authenticator app" />
)}
{needsWebAuthn && (
<Box sx={{ mt: 2, mb: 2 }}>
<Button
fullWidth
variant="contained"
size="large"
startIcon={<KeyIcon />}
onClick={handleWebAuthnLogin}
disabled={webAuthnLoading}
sx={{ mb: 1 }}
>
{webAuthnLoading ? <CircularProgress size={24} /> : 'Use Security Key'}
</Button>
<Typography variant="caption" color="text.secondary" display="block" textAlign="center">
Touch your security key or use your device biometrics to authenticate.
</Typography>
</Box>
)}
<Button type="submit" fullWidth variant="contained" size="large" sx={{ mt: 3 }} disabled={loading}>{loading ? <CircularProgress size={24} /> : 'Sign In'}</Button>
{ssoEnabled && (
<>
<Divider sx={{ my: 3 }}>or</Divider>
<Button fullWidth variant="outlined" size="large" startIcon={ssoIcon} onClick={() => { window.location.href = ssoAuthUrl }} disabled={loading}>Sign in with {ssoDisplayName}</Button>
<Button fullWidth variant="outlined" size="large" startIcon={ssoIcon} onClick={() => { const state = Array.from(crypto.getRandomValues(new Uint8Array(16))).map(b => b.toString(16).padStart(2, '0')).join(''); sessionStorage.setItem('sso_csrf_state', state); window.location.href = ssoAuthUrl }} disabled={loading}>Sign in with {ssoDisplayName}</Button>
</>
)}
</Box>

View File

@ -1,17 +1,45 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useState, useCallback } from 'react'
import {
Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, Stepper, Step, StepLabel,
IconButton, Tooltip, Snackbar,
IconButton, Tooltip, Snackbar, Tabs, Tab, List, ListItem,
ListItemText, ListItemSecondaryAction, Dialog, DialogTitle,
DialogContent, DialogActions,
} from '@mui/material'
import { ContentCopy as CopyIcon } from '@mui/icons-material'
import {
ContentCopy as CopyIcon,
Delete as DeleteIcon,
VpnKey as KeyIcon,
Add as AddIcon,
} from '@mui/icons-material'
import QRCode from 'qrcode'
import { authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
const STEPS = ['Get your QR code', 'Verify code', 'Done']
export default function MfaSetupPage() {
// ── WebAuthn utility functions ──────────────────────────────────────────────
function arrayBufferToBase64url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function base64urlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const binary = atob(base64 + padding)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
// ── TOTP Setup Component ────────────────────────────────────────────────────
function TotpSetup() {
const [step, setStep] = useState(0)
const [setup, setSetup] = useState<{ secret_base32: string; otp_uri: string } | null>(null)
const [code, setCode] = useState('')
@ -24,8 +52,6 @@ export default function MfaSetupPage() {
authApi.getMfaSetup()
.then((res) => {
setSetup(res.data)
console.warn('[MFA Setup] Success:', res.status, res.data)
// Generate QR code from otpauth URI
if (res.data.otp_uri) {
QRCode.toDataURL(res.data.otp_uri, {
width: 256,
@ -33,21 +59,14 @@ export default function MfaSetupPage() {
color: { dark: '#000000', light: '#ffffff' },
})
.then((url) => setQrDataUrl(url))
.catch((qrErr) => {
console.error('[MFA Setup] QR generation failed:', qrErr)
setError('Failed to generate QR code.')
})
.catch(() => setError('Failed to generate QR code.'))
} else {
console.error('[MFA Setup] No otp_uri in response:', res.data)
setError('MFA setup returned invalid data. No OTP URI found.')
}
})
.catch((err) => {
const status = err?.response?.status
const data = err?.response?.data
const message = err?.message
const token = useAuthStore.getState().accessToken
console.error('[MFA Setup] Failed:', { status, data, message, hasToken: !!token })
if (status === 401) {
setError('Authentication required. Please log in again.')
} else if (status === 403) {
@ -84,83 +103,65 @@ export default function MfaSetupPage() {
}
return (
<Container maxWidth="sm" sx={{ mt: 6 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h5" fontWeight={700} mb={3}>Set Up MFA</Typography>
<Stepper activeStep={step} sx={{ mb: 4 }}>
{STEPS.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
</Stepper>
<Box>
<Stepper activeStep={step} sx={{ mb: 4 }}>
{STEPS.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
</Stepper>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{step === 0 && setup && (
<Box>
<Typography mb={2}>
Scan this QR code in your authenticator app:
</Typography>
{qrDataUrl ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<img
src={qrDataUrl}
alt="MFA QR Code"
width={256}
height={256}
style={{ imageRendering: 'pixelated' }}
/>
</Box>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<CircularProgress />
</Box>
)}
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
If you can't scan the QR code, enter the secret manually:
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
wordBreak: 'break-all',
p: 1,
bgcolor: 'grey.100',
borderRadius: 1,
flexGrow: 1,
}}
>
{setup.secret_base32}
</Typography>
<Tooltip title={copied ? 'Copied!' : 'Copy Secret'}>
<IconButton onClick={handleCopySecret} color={copied ? 'success' : 'default'}>
<CopyIcon />
</IconButton>
</Tooltip>
{step === 0 && setup && (
<Box>
<Typography mb={2}>Scan this QR code in your authenticator app:</Typography>
{qrDataUrl ? (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<img src={qrDataUrl} alt="MFA QR Code" width={256} height={256} style={{ imageRendering: 'pixelated' }} />
</Box>
<Button variant="contained" onClick={() => setStep(1)}>Continue</Button>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<CircularProgress />
</Box>
)}
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
If you can't scan the QR code, enter the secret manually:
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography
variant="body2"
sx={{ fontFamily: 'monospace', wordBreak: 'break-all', p: 1, bgcolor: 'grey.100', borderRadius: 1, flexGrow: 1 }}
>
{setup.secret_base32}
</Typography>
<Tooltip title={copied ? 'Copied!' : 'Copy Secret'}>
<IconButton onClick={handleCopySecret} color={copied ? 'success' : 'default'}>
<CopyIcon />
</IconButton>
</Tooltip>
</Box>
)}
<Button variant="contained" onClick={() => setStep(1)}>Continue</Button>
</Box>
)}
{step === 1 && (
<Box component="form" onSubmit={handleVerify}>
<Typography mb={2}>Enter the 6-digit code from your authenticator app to confirm setup:</Typography>
<TextField
fullWidth label="Verification Code" inputMode="numeric"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
value={code} onChange={(e) => setCode(e.target.value)}
disabled={loading} required autoFocus
/>
<Button type="submit" variant="contained" sx={{ mt: 2 }} disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Verify & Enable MFA'}
</Button>
</Box>
)}
{step === 1 && (
<Box component="form" onSubmit={handleVerify}>
<Typography mb={2}>Enter the 6-digit code from your authenticator app to confirm setup:</Typography>
<TextField
fullWidth label="Verification Code" inputMode="numeric"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
value={code} onChange={(e) => setCode(e.target.value)}
disabled={loading} required autoFocus
/>
<Button type="submit" variant="contained" sx={{ mt: 2 }} disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Verify & Enable MFA'}
</Button>
</Box>
)}
{step === 2 && (
<Alert severity="success">
MFA has been enabled for your account. You will need your authenticator app at each login.
</Alert>
)}
</Paper>
{step === 2 && (
<Alert severity="success">
MFA has been enabled for your account. You will need your authenticator app at each login.
</Alert>
)}
<Snackbar
open={copied}
@ -170,6 +171,231 @@ export default function MfaSetupPage() {
>
<Alert severity="success" variant="filled">Secret copied to clipboard</Alert>
</Snackbar>
</Box>
)
}
// ── WebAuthn Setup Component ────────────────────────────────────────────────
interface WebAuthnCredential {
id: string
name: string
created_at: string
}
function WebAuthnSetup() {
const [credentials, setCredentials] = useState<WebAuthnCredential[]>([])
const [loading, setLoading] = useState(false)
const [registering, setRegistering] = useState(false)
const [keyName, setKeyName] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const loadCredentials = useCallback(() => {
authApi.webauthnListCredentials()
.then((res) => {
setCredentials(res.data.credentials || [])
})
.catch((err) => {
console.error('[WebAuthn] Failed to load credentials:', err)
setError('Failed to load security keys.')
})
}, [])
useEffect(() => {
loadCredentials()
}, [loadCredentials])
const handleRegister = async () => {
setRegistering(true)
setError(null)
setSuccess(null)
try {
// Step 1: Start registration ceremony
const startRes = await authApi.webauthnRegisterStart(keyName || undefined)
const { challenge_key, creation_options } = startRes.data
// Step 2: Convert base64url strings to ArrayBuffers for navigator.credentials.create
const publicKey = creation_options.publicKey
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
...publicKey,
challenge: base64urlToArrayBuffer(publicKey.challenge),
user: {
...publicKey.user,
id: base64urlToArrayBuffer(publicKey.user.id),
},
excludeCredentials: publicKey.excludeCredentials?.map((c: { type: string; id: string }) => ({
...c,
id: base64urlToArrayBuffer(c.id),
})),
}
// Step 3: Create credential via browser WebAuthn API
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions,
}) as PublicKeyCredential | null
if (!credential) {
setError('Security key registration was cancelled.')
return
}
// Step 4: Serialize credential for server
const response = credential.response as AuthenticatorAttestationResponse
const serializedCredential = {
id: credential.id,
rawId: arrayBufferToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: arrayBufferToBase64url(response.attestationObject),
clientDataJSON: arrayBufferToBase64url(response.clientDataJSON),
},
}
// Step 5: Complete registration
await authApi.webauthnRegisterComplete(challenge_key, serializedCredential, keyName || undefined)
setSuccess('Security key registered successfully!')
setKeyName('')
loadCredentials()
} catch (err: unknown) {
const errorObj = err as { name?: string; response?: { data?: { error?: { message?: string } } }; message?: string }
if (errorObj.name === 'NotAllowedError') {
setError('Security key registration was cancelled or timed out.')
} else {
const msg = errorObj.response?.data?.error?.message || errorObj.message || 'Registration failed.'
setError(`Failed to register security key: ${msg}`)
}
} finally {
setRegistering(false)
}
}
const handleDelete = async (id: string) => {
setDeleteConfirm(null)
setLoading(true)
setError(null)
try {
await authApi.webauthnDeleteCredential(id)
setSuccess('Security key removed successfully.')
loadCredentials()
} catch (err: unknown) {
const errorObj = err as { response?: { data?: { error?: { message?: string } } }; message?: string }
const msg = errorObj.response?.data?.error?.message || errorObj.message || 'Failed to delete key.'
setError(`Failed to remove security key: ${msg}`)
} finally {
setLoading(false)
}
}
return (
<Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
<Typography variant="h6" fontWeight={600} mb={2}>
Register a Security Key
</Typography>
<Typography variant="body2" color="text.secondary" mb={2}>
Add a FIDO2/WebAuthn security key (e.g., YubiKey) for passwordless authentication.
You can register multiple keys as backups.
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 3, alignItems: 'flex-start' }}>
<TextField
size="small"
label="Key Name (optional)"
placeholder="e.g., My YubiKey"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
disabled={registering}
sx={{ flexGrow: 1 }}
/>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleRegister}
disabled={registering}
>
{registering ? <CircularProgress size={24} /> : 'Register Security Key'}
</Button>
</Box>
<Typography variant="h6" fontWeight={600} mb={1}>
Registered Security Keys
</Typography>
{credentials.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
No security keys registered yet.
</Typography>
) : (
<List>
{credentials.map((cred) => (
<ListItem key={cred.id} sx={{ bgcolor: 'grey.50', borderRadius: 1, mb: 1 }}>
<KeyIcon sx={{ mr: 2, color: 'action.active' }} />
<ListItemText
primary={cred.name || 'Unnamed Key'}
secondary={`Added ${new Date(cred.created_at).toLocaleDateString()}`}
/>
<ListItemSecondaryAction>
<Tooltip title="Remove Key">
<IconButton
edge="end"
color="error"
onClick={() => setDeleteConfirm(cred.id)}
disabled={loading}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{/* Delete confirmation dialog */}
<Dialog open={!!deleteConfirm} onClose={() => setDeleteConfirm(null)}>
<DialogTitle>Remove Security Key?</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to remove this security key? You will no longer be able to use it to sign in.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
color="error"
variant="contained"
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
>
Remove
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
// ── Main MFA Setup Page ─────────────────────────────────────────────────────
export default function MfaSetupPage() {
const [activeTab, setActiveTab] = useState(0)
return (
<Container maxWidth="sm" sx={{ mt: 6 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h5" fontWeight={700} mb={3}>Set Up MFA</Typography>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} sx={{ mb: 3 }}>
<Tab label="Authenticator App" icon={<CopyIcon />} iconPosition="start" />
<Tab label="Security Key" icon={<KeyIcon />} iconPosition="start" />
</Tabs>
{activeTab === 0 && <TotpSetup />}
{activeTab === 1 && <WebAuthnSetup />}
</Paper>
</Container>
)
}