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>
448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
'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'
|
|
}
|
|
}
|