Move allauth + auth UI to legacy/

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>
This commit is contained in:
2026-04-07 03:41:22 -04:00
parent 24ff0ae66d
commit 27c30d7e50
50 changed files with 0 additions and 8 deletions

View File

@@ -0,0 +1,447 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useAuth, useAuthContext, useFeatures } from '../contexts/AuthContext'
import { useAllauthAPI } from '../contexts/APIContext'
import { useStyles } from '../contexts/StylesContext'
import { getAuthDetails } from '../api'
import { AuthenticatorType } from '../defines'
import { AuthSettings } from './settings/AuthSettings'
import { AuthCard } from './AuthCard'
import { AuthDjangoForm } from './AuthDjangoForm'
import { Button } from './settings/SettingsComponents'
import { LoginView } from './views/LoginView'
import { SignupView } from './views/SignupView'
import { MFAChooserView } from './views/MFAChooserView'
import { MFAWebAuthnView } from './views/MFAWebAuthnView'
import { MFATOTPView } from './views/MFATOTPView'
import { MFARecoveryCodesView } from './views/MFARecoveryCodesView'
/**
* All possible views in the AllauthUI component.
* Views are rendered based on state, not URLs.
*/
export type AllauthUIView =
// Auth views (for unauthenticated users)
| 'login'
| 'signup'
| 'resetPassword'
| 'resetPasswordSent'
| 'requestCode'
| 'confirmCode'
// MFA views (during auth flow)
| 'mfaChooser'
| 'mfaTotp'
| 'mfaWebauthn'
| 'mfaRecoveryCodes'
// Authenticated views
| 'settings'
| 'logout'
/**
* Controls how AllauthUI behaves regarding auth/settings transitions.
*
* - `'auto'` (default): Full SPA - shows auth views when not authenticated,
* automatically transitions to settings after login, and back to login after logout.
*
* - `'auth'`: Auth-only mode - only shows auth views (login, signup, MFA, etc.).
* Never shows settings. Use `onAuthenticated` to handle post-login navigation.
* Ideal for a dedicated login page.
*
* - `'settings'`: Settings-only mode - only shows settings views.
* If not authenticated, calls `onUnauthenticated` or shows nothing.
* Ideal for a dedicated settings page.
*/
export type AllauthUIMode = 'auto' | 'auth' | 'settings'
interface AllauthUIProps {
/**
* Controls auth/settings transition behavior.
* @default 'auto'
*/
mode?: AllauthUIMode
/**
* Initial view when component mounts (for 'auto' and 'auth' modes).
* Defaults to 'login' for unauthenticated, 'settings' for authenticated (in auto mode).
*/
initialView?: AllauthUIView
/**
* Called when authentication completes successfully.
* Required for 'auth' mode to handle post-login navigation.
*/
onAuthenticated?: () => void
/**
* Called when user is not authenticated (for 'settings' mode).
* Use this to redirect to login page.
*/
onUnauthenticated?: () => void
/**
* Called when user logs out.
* In 'auto' mode, defaults to showing login view.
*/
onLogout?: () => void
/**
* Which settings sections to show.
* Defaults to all sections.
*/
settingsSections?: Array<'profile' | 'emails' | 'password' | 'passkeys' | 'connections' | 'mfa' | 'sessions'>
/**
* OAuth callback URL for social login providers.
*/
oauthCallbackUrl?: string
}
/**
* AllauthUI is the main component for rendering auth UI.
*
* It can operate in three modes:
* - `'auto'` (default): Full SPA handling login, MFA, settings, and logout
* - `'auth'`: Auth-only for dedicated login pages
* - `'settings'`: Settings-only for dedicated settings pages
*
* @example
* ```tsx
* // Full SPA mode (default) - handles everything
* <AllauthUI />
*
* // Auth-only mode - for a dedicated login page
* <AllauthUI mode="auth" onAuthenticated={() => router.push('/dashboard')} />
*
* // Settings-only mode - for a dedicated settings page
* <AllauthUI mode="settings" onUnauthenticated={() => router.push('/login')} />
* ```
*/
export function AllauthUI({
mode = 'auto',
initialView,
onAuthenticated,
onUnauthenticated,
onLogout,
settingsSections,
oauthCallbackUrl,
}: AllauthUIProps) {
const { isAuthenticated, pendingFlow } = useAuth()
const { refresh } = useAuthContext()
const api = useAllauthAPI()
const styles = useStyles()
const features = useFeatures()
// Get available MFA types from pending flow
const mfaTypes = pendingFlow?.types || []
// Internal view state
const [view, setView] = useState<AllauthUIView>(() => {
if (initialView) return initialView
// Settings mode always starts at settings
if (mode === 'settings') return 'settings'
// Auth mode always starts at login (or MFA if pending)
if (mode === 'auth') {
if (pendingFlow) {
return mfaTypes.length === 1 ? getMFAView(mfaTypes[0]) : 'mfaChooser'
}
return 'login'
}
// Auto mode: settings if authenticated, login otherwise
if (isAuthenticated) return 'settings'
if (pendingFlow) {
return mfaTypes.length === 1 ? getMFAView(mfaTypes[0]) : 'mfaChooser'
}
return 'login'
})
// Track auth state changes
const wasAuthenticated = useRef(isAuthenticated)
const hadPendingFlow = useRef(!!pendingFlow)
// Handle auth state transitions
useEffect(() => {
// User just became authenticated
if (!wasAuthenticated.current && isAuthenticated) {
if (onAuthenticated) {
onAuthenticated()
} else if (mode === 'auto') {
setView('settings')
}
// In 'auth' mode without onAuthenticated, do nothing (stay on current view)
}
// User just logged out
if (wasAuthenticated.current && !isAuthenticated) {
if (onLogout) {
onLogout()
} else if (mode === 'auto') {
setView('login')
} else if (mode === 'settings' && onUnauthenticated) {
onUnauthenticated()
}
}
wasAuthenticated.current = isAuthenticated
}, [isAuthenticated, onAuthenticated, onUnauthenticated, onLogout, mode])
// Handle MFA flow transitions
useEffect(() => {
if (pendingFlow && !hadPendingFlow.current) {
// New MFA flow started
if (mfaTypes.length === 1) {
setView(getMFAView(mfaTypes[0]))
} else if (mfaTypes.length > 1) {
setView('mfaChooser')
}
}
if (!pendingFlow && hadPendingFlow.current && isAuthenticated) {
// MFA completed successfully
if (onAuthenticated) {
onAuthenticated()
} else if (mode === 'auto') {
setView('settings')
}
}
hadPendingFlow.current = !!pendingFlow
}, [pendingFlow, mfaTypes, isAuthenticated, onAuthenticated, mode])
// Settings mode: handle unauthenticated state
useEffect(() => {
if (mode === 'settings' && !isAuthenticated && onUnauthenticated) {
onUnauthenticated()
}
}, [mode, isAuthenticated, onUnauthenticated])
// Handle logout
const handleLogout = async () => {
await api.session.logout()
await refresh()
if (onLogout) {
onLogout()
} else if (mode === 'auto') {
setView('login')
}
// In settings mode, the useEffect will call onUnauthenticated
}
// Called after successful login/signup - check for MFA or complete auth
const handleAuthSuccess = async () => {
const newAuth = await refresh()
const details = getAuthDetails(newAuth)
// If fully authenticated, handle completion
if (details.isAuthenticated) {
if (onAuthenticated) {
onAuthenticated()
} else if (mode === 'auto') {
setView('settings')
}
// In 'auth' mode without onAuthenticated, stay on current view
}
// If MFA pending, the useEffect will handle the view transition
}
// Render based on current view
switch (view) {
// ============================================
// Authenticated views
// ============================================
case 'settings':
// In auth mode, never show settings
if (mode === 'auth') {
return null
}
// Not authenticated - handle based on mode
if (!isAuthenticated) {
if (mode === 'settings' && onUnauthenticated) {
// Will be handled by useEffect
return null
}
// Auto mode: switch to login
setView('login')
return null
}
return (
<AuthSettings
sections={settingsSections}
onSignOut={() => setView('logout')}
/>
)
case 'logout':
if (!isAuthenticated) {
if (mode === 'auto') {
setView('login')
}
return null
}
return (
<AuthCard
title="Sign Out"
subtitle="Are you sure you want to sign out?"
footerLinks={[
{ label: 'Cancel', onClick: () => setView('settings') },
]}
>
<div className={styles.form}>
<Button onClick={handleLogout}>
Sign Out
</Button>
</div>
</AuthCard>
)
// ============================================
// MFA views
// ============================================
case 'mfaChooser':
return (
<MFAChooserView
types={mfaTypes}
onSuccess={handleAuthSuccess}
onCancel={() => setView('login')}
/>
)
case 'mfaTotp':
return (
<MFATOTPView
onSuccess={handleAuthSuccess}
onCancel={() => setView('login')}
onBack={mfaTypes.length > 1 ? () => setView('mfaChooser') : undefined}
/>
)
case 'mfaWebauthn':
return (
<MFAWebAuthnView
onSuccess={handleAuthSuccess}
onCancel={() => setView('login')}
onBack={mfaTypes.length > 1 ? () => setView('mfaChooser') : undefined}
/>
)
case 'mfaRecoveryCodes':
return (
<MFARecoveryCodesView
onSuccess={handleAuthSuccess}
onCancel={() => setView('login')}
onBack={mfaTypes.length > 1 ? () => setView('mfaChooser') : undefined}
/>
)
// ============================================
// Password reset views
// ============================================
case 'resetPassword':
return (
<AuthDjangoForm
formName="reset_password"
onSuccess={() => setView('resetPasswordSent')}
footerLinks={[
{ label: 'Back to Sign In', onClick: () => setView('login') },
]}
/>
)
case 'resetPasswordSent':
return (
<AuthCard
title="Check Your Email"
subtitle="If an account exists with that email, we've sent password reset instructions."
footerLinks={[
{ label: 'Back to Sign In', onClick: () => setView('login') },
]}
/>
)
// ============================================
// Login by code views
// ============================================
case 'requestCode':
// If login by code is disabled, redirect to login
if (!features.loginByCodeEnabled) {
setView('login')
return null
}
return (
<AuthDjangoForm
formName="request_login_code"
onSuccess={() => setView('confirmCode')}
footerLinks={[
{ label: 'Sign in with password instead', onClick: () => setView('login') },
]}
/>
)
case 'confirmCode':
// If login by code is disabled, redirect to login
if (!features.loginByCodeEnabled) {
setView('login')
return null
}
return (
<AuthDjangoForm
formName="confirm_login_code"
onSuccess={handleAuthSuccess}
footerLinks={[
{ label: 'Request a new code', onClick: () => setView('requestCode') },
{ label: 'Sign in with password instead', onClick: () => setView('login') },
]}
/>
)
// ============================================
// Signup view
// ============================================
case 'signup':
// If signup is disabled, redirect to login
if (!features.signupEnabled) {
setView('login')
return null
}
return (
<SignupView
onSuccess={handleAuthSuccess}
onLoginClick={() => setView('login')}
/>
)
// ============================================
// Login view (default)
// ============================================
case 'login':
default:
return (
<LoginView
onSuccess={handleAuthSuccess}
// Only provide signup callback if signups are enabled
onSignupClick={features.signupEnabled ? () => setView('signup') : undefined}
onForgotPasswordClick={() => setView('resetPassword')}
// Only provide login-by-code callback if feature is enabled
onLoginByCodeClick={features.loginByCodeEnabled ? () => setView('requestCode') : undefined}
oauthCallbackUrl={oauthCallbackUrl}
/>
)
}
}
/**
* Get the view name for a given MFA authenticator type.
*/
function getMFAView(type: string): AllauthUIView {
switch (type) {
case AuthenticatorType.TOTP:
return 'mfaTotp'
case AuthenticatorType.WEBAUTHN:
return 'mfaWebauthn'
case AuthenticatorType.RECOVERY_CODES:
return 'mfaRecoveryCodes'
default:
return 'mfaChooser'
}
}