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>
172 lines
5.8 KiB
TypeScript
172 lines
5.8 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useAllauthAPI } from '../../contexts/APIContext'
|
|
import { useStyles } from '../../contexts/StylesContext'
|
|
import { SettingsSection, SettingsItem, Badge, Button } from './SettingsComponents'
|
|
import type { Authenticator, TOTPStatus } from '../../types'
|
|
|
|
interface TOTPSetup {
|
|
secret: string
|
|
totp_url: string
|
|
}
|
|
|
|
export function MFASection() {
|
|
const api = useAllauthAPI()
|
|
const [authenticators, setAuthenticators] = useState<Authenticator[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [available, setAvailable] = useState(true)
|
|
|
|
const fetchAuthenticators = async () => {
|
|
try {
|
|
const res = await api.mfa.list()
|
|
if (res.status === 200 && res.data) {
|
|
setAuthenticators(res.data as Authenticator[])
|
|
} else {
|
|
// Non-200 status means MFA not available
|
|
setAvailable(false)
|
|
}
|
|
} catch {
|
|
setAvailable(false)
|
|
}
|
|
setLoading(false)
|
|
}
|
|
|
|
useEffect(() => { fetchAuthenticators() }, [])
|
|
|
|
if (loading || !available) return null
|
|
|
|
const hasTOTP = authenticators.some(a => a.type === 'totp')
|
|
|
|
return (
|
|
<SettingsSection title="Two-Factor Authentication">
|
|
<TOTPSubsection
|
|
hasTOTP={hasTOTP}
|
|
onUpdate={fetchAuthenticators}
|
|
/>
|
|
|
|
{hasTOTP && (
|
|
<RecoveryCodesSubsection />
|
|
)}
|
|
</SettingsSection>
|
|
)
|
|
}
|
|
|
|
// --- TOTP Subsection ---
|
|
|
|
function TOTPSubsection({ hasTOTP, onUpdate }: { hasTOTP: boolean; onUpdate: () => void }) {
|
|
const api = useAllauthAPI()
|
|
const styles = useStyles()
|
|
const [showSetup, setShowSetup] = useState(false)
|
|
const [setup, setSetup] = useState<TOTPSetup | null>(null)
|
|
const [code, setCode] = useState('')
|
|
|
|
const handleStartSetup = async () => {
|
|
const res = await api.mfa.totp.getStatus()
|
|
// allauth returns TOTP status with secret and totp_url for setup
|
|
const data = res.data as TOTPStatus | undefined
|
|
if (data?.secret && data?.totp_url) {
|
|
setSetup({ secret: data.secret, totp_url: data.totp_url })
|
|
setShowSetup(true)
|
|
}
|
|
}
|
|
|
|
const handleActivate = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
const res = await api.mfa.totp.activate(code)
|
|
if (res.status === 200) {
|
|
setShowSetup(false)
|
|
setSetup(null)
|
|
setCode('')
|
|
onUpdate()
|
|
}
|
|
}
|
|
|
|
const handleDeactivate = async () => {
|
|
if (!confirm('Disable authenticator app?')) return
|
|
await api.mfa.totp.deactivate()
|
|
onUpdate()
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<h3 className={styles.settingsSubtitle}>Authenticator App</h3>
|
|
|
|
{showSetup && setup ? (
|
|
<div className={styles.totpSetup}>
|
|
<p>Scan this QR code with your authenticator app:</p>
|
|
<img
|
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(setup.totp_url)}`}
|
|
alt="TOTP QR Code"
|
|
className={styles.qrCode}
|
|
/>
|
|
<p className={styles.settingsItemMeta}>Secret: {setup.secret}</p>
|
|
<form onSubmit={handleActivate} className={styles.inlineForm}>
|
|
<div className={styles.field}>
|
|
<input
|
|
type="text"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
placeholder="Verification Code"
|
|
className={styles.fieldInput}
|
|
/>
|
|
</div>
|
|
<Button type="submit">Activate</Button>
|
|
<Button type="button" variant="secondary" onClick={() => setShowSetup(false)}>
|
|
Cancel
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
) : hasTOTP ? (
|
|
<SettingsItem
|
|
label={<>Authenticator App <Badge variant="success">Active</Badge></>}
|
|
actions={<Button variant="danger" onClick={handleDeactivate}>Disable</Button>}
|
|
/>
|
|
) : (
|
|
<Button onClick={handleStartSetup}>Set Up Authenticator</Button>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// --- Recovery Codes Subsection ---
|
|
|
|
function RecoveryCodesSubsection() {
|
|
const api = useAllauthAPI()
|
|
const styles = useStyles()
|
|
const [codes, setCodes] = useState<string[]>([])
|
|
|
|
const handleView = async () => {
|
|
const res = await api.mfa.recoveryCodes.list()
|
|
if (res.status === 200) {
|
|
setCodes(res.data?.unused_codes || [])
|
|
}
|
|
}
|
|
|
|
const handleRegenerate = async () => {
|
|
if (!confirm('Generate new codes? Old codes will stop working.')) return
|
|
const res = await api.mfa.recoveryCodes.regenerate()
|
|
if (res.status === 200) {
|
|
setCodes(res.data?.unused_codes || [])
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<h3 className={styles.settingsSubtitle}>Recovery Codes</h3>
|
|
|
|
{codes.length > 0 ? (
|
|
<div>
|
|
<div className={styles.recoveryCodes}>
|
|
{codes.map((code, i) => <span key={i}>{code}</span>)}
|
|
</div>
|
|
<p className={styles.settingsItemMeta}>Store these safely. Each code works once.</p>
|
|
<Button variant="secondary" onClick={handleRegenerate}>Regenerate</Button>
|
|
</div>
|
|
) : (
|
|
<Button variant="secondary" onClick={handleView}>View Recovery Codes</Button>
|
|
)}
|
|
</>
|
|
)
|
|
}
|