allauth/ (44 files) is a django-allauth React UI — a separate concern from the Mizan protocol. Moved to legacy/ pending extraction into a standalone mizan-django-allauth package. Also moved to legacy/: - client/AuthContext.tsx — generic auth state from /me endpoint - client/RouterContext.tsx — framework-agnostic router adapter - client/routing.tsx — UserRoute/StaffRoute/AnonymousRoute guards - client/nextjs.tsx — Next.js router adapter for auth These are auth UI infrastructure, not Mizan protocol. The Mizan core only needs JWT for auth header selection (jwt/ stays — MizanProvider depends on useJWT() to decide between Bearer and session auth). Cleaned up re-exports in client/react.ts and vitest aliases. 33 React tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
138 lines
4.5 KiB
TypeScript
138 lines
4.5 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { AuthenticatorType } from '../../defines'
|
|
import { useAllauthAPI } from '../../contexts/APIContext'
|
|
import { useStyles } from '../../contexts/StylesContext'
|
|
import { AuthCard } from '../AuthCard'
|
|
import { MFATOTPView } from './MFATOTPView'
|
|
import { MFAWebAuthnView } from './MFAWebAuthnView'
|
|
import { MFARecoveryCodesView } from './MFARecoveryCodesView'
|
|
|
|
const MFA_OPTIONS: Record<string, { label: string; description: string }> = {
|
|
[AuthenticatorType.WEBAUTHN]: {
|
|
label: 'Security Key / Passkey',
|
|
description: 'Use your registered security key or passkey',
|
|
},
|
|
[AuthenticatorType.TOTP]: {
|
|
label: 'Authenticator App',
|
|
description: 'Enter a code from your authenticator app',
|
|
},
|
|
[AuthenticatorType.RECOVERY_CODES]: {
|
|
label: 'Recovery Code',
|
|
description: 'Use one of your recovery codes',
|
|
},
|
|
}
|
|
|
|
interface MFAChooserViewProps {
|
|
types: string[]
|
|
onSuccess?: () => void
|
|
onCancel?: () => void
|
|
isReauth?: boolean
|
|
}
|
|
|
|
export function MFAChooserView({ types, onSuccess, onCancel, isReauth }: MFAChooserViewProps) {
|
|
const api = useAllauthAPI()
|
|
const styles = useStyles()
|
|
const [selectedType, setSelectedType] = useState<string | null>(null)
|
|
const [cancelling, setCancelling] = useState(false)
|
|
|
|
// Filter to only show options that are available
|
|
const availableOptions = types
|
|
.filter(type => MFA_OPTIONS[type])
|
|
.map(type => ({ type, ...MFA_OPTIONS[type] }))
|
|
|
|
const handleCancel = async () => {
|
|
setCancelling(true)
|
|
try {
|
|
await api.session.logout()
|
|
onCancel?.()
|
|
} catch {
|
|
setCancelling(false)
|
|
}
|
|
}
|
|
|
|
const handleBack = types.length > 1 ? () => setSelectedType(null) : undefined
|
|
|
|
// If a type is selected, show that method's view
|
|
if (selectedType === AuthenticatorType.TOTP) {
|
|
return (
|
|
<MFATOTPView
|
|
onSuccess={onSuccess}
|
|
onCancel={onCancel}
|
|
onBack={handleBack}
|
|
isReauth={isReauth}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (selectedType === AuthenticatorType.WEBAUTHN) {
|
|
return (
|
|
<MFAWebAuthnView
|
|
onSuccess={onSuccess}
|
|
onCancel={onCancel}
|
|
onBack={handleBack}
|
|
isReauth={isReauth}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (selectedType === AuthenticatorType.RECOVERY_CODES) {
|
|
return (
|
|
<MFARecoveryCodesView
|
|
onSuccess={onSuccess}
|
|
onCancel={onCancel}
|
|
onBack={handleBack}
|
|
isReauth={isReauth}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Show chooser
|
|
if (availableOptions.length === 0) {
|
|
return (
|
|
<AuthCard
|
|
title="Two-Factor Authentication"
|
|
subtitle="No authentication methods available."
|
|
footerLinks={onCancel ? [
|
|
{ label: 'Cancel and go back', onClick: handleCancel },
|
|
] : []}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<div className={styles.card}>
|
|
<h1 className={styles.title}>Two-Factor Authentication</h1>
|
|
<p className={styles.subtitle}>Choose how you want to verify your identity.</p>
|
|
|
|
<div className={styles.form}>
|
|
{availableOptions.map(option => (
|
|
<button
|
|
key={option.type}
|
|
onClick={() => setSelectedType(option.type)}
|
|
className={styles.providerButton}
|
|
>
|
|
<div style={{ textAlign: 'left' }}>
|
|
<div style={{ fontWeight: 600 }}>{option.label}</div>
|
|
<div style={{ fontSize: '0.8125rem', opacity: 0.7, marginTop: '0.25rem' }}>
|
|
{option.description}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{onCancel && (
|
|
<div className={styles.footer}>
|
|
<button onClick={handleCancel} disabled={cancelling} className={styles.link}>
|
|
{cancelling ? 'Cancelling...' : 'Cancel and go back'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|