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:
447
legacy/allauth/components/AllauthUI.tsx
Normal file
447
legacy/allauth/components/AllauthUI.tsx
Normal 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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user