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,220 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from '../contexts/RouterContext'
import { useAllauthAPI } from '../contexts/APIContext'
import { useAllauthConfig } from '../contexts/ConfigContext'
import { DjangoFlowPaths } from '../config'
import { AuthCard } from './AuthCard'
import { AuthDjangoForm } from './AuthDjangoForm'
interface AllauthRouterProps {
/** Called after successful completion of any flow */
onComplete?: () => void
/** Called when user wants to go back to login */
onLoginClick?: () => void
}
/**
* AllauthRouter handles Django-initiated flows (email verification, password reset, OAuth).
*
* Mount this at a catch-all route matching your basePath config:
* app/auth/[...path]/page.tsx -> <AllauthRouter />
*
* The path determines which flow to render:
* /auth/verify-email/[key] -> Email verification
* /auth/reset-password?key=xxx -> Password reset form
* /auth/oauth/callback -> OAuth completion
*/
export function AllauthRouter({ onComplete, onLoginClick }: AllauthRouterProps) {
const router = useRouter()
const config = useAllauthConfig()
// Parse the path segments after basePath
// The router provides getParam('path') which returns the catch-all segments
const pathParam = router.getParam('path')
const pathSegments = Array.isArray(pathParam) ? pathParam : pathParam ? [pathParam] : []
const path = pathSegments.length > 0 ? `/${pathSegments.join('/')}` : '/'
// Determine which flow based on path
if (path.startsWith(DjangoFlowPaths.VERIFY_EMAIL)) {
const key = pathSegments[1] || router.searchParams.get('key')
return (
<EmailVerifyView
verificationKey={key}
onComplete={onComplete}
onLoginClick={onLoginClick}
/>
)
}
if (path.startsWith(DjangoFlowPaths.RESET_PASSWORD)) {
const key = pathSegments[1] || router.searchParams.get('key')
return (
<PasswordResetView
resetKey={key}
onComplete={onComplete}
onLoginClick={onLoginClick}
/>
)
}
if (path.startsWith(DjangoFlowPaths.OAUTH_ERROR)) {
return (
<OAuthErrorView
onLoginClick={onLoginClick}
/>
)
}
// Unknown path
return (
<AuthCard
title="Not Found"
subtitle="This page doesn't exist."
footerLinks={onLoginClick ? [
{ label: 'Back to Sign In', onClick: onLoginClick },
] : []}
/>
)
}
// ----------------------------------------------------------------------------
// Email Verification View
// ----------------------------------------------------------------------------
interface EmailVerifyViewProps {
verificationKey: string | null | undefined
onComplete?: () => void
onLoginClick?: () => void
}
function EmailVerifyView({ verificationKey, onComplete, onLoginClick }: EmailVerifyViewProps) {
const api = useAllauthAPI()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [error, setError] = useState('')
useEffect(() => {
if (!verificationKey) {
setStatus('error')
setError('Invalid verification link')
return
}
const verify = async () => {
const res = await api.account.emails.verification.confirmKey(verificationKey)
if (res.status === 200) {
setStatus('success')
if (onComplete) {
setTimeout(onComplete, 2000)
}
} else {
setStatus('error')
setError(res.errors?.[0]?.message || 'Invalid or expired verification link')
}
}
verify()
}, [verificationKey, api, onComplete])
if (status === 'loading') {
return <AuthCard title="" loading loadingText="Verifying your email..." />
}
if (status === 'success') {
return (
<AuthCard
title="Email Verified"
subtitle="Your email has been verified successfully."
/>
)
}
return (
<AuthCard
title="Verification Failed"
error={error}
footerLinks={onLoginClick ? [
{ label: 'Back to Sign In', onClick: onLoginClick },
] : []}
/>
)
}
// ----------------------------------------------------------------------------
// Password Reset View
// ----------------------------------------------------------------------------
interface PasswordResetViewProps {
resetKey: string | null | undefined
onComplete?: () => void
onLoginClick?: () => void
}
function PasswordResetView({ resetKey, onComplete, onLoginClick }: PasswordResetViewProps) {
const [success, setSuccess] = useState(false)
if (!resetKey) {
return (
<AuthCard
title="Invalid Link"
subtitle="This password reset link is invalid or has expired."
footerLinks={onLoginClick ? [
{ label: 'Back to Sign In', onClick: onLoginClick },
] : []}
/>
)
}
if (success) {
return (
<AuthCard
title="Password Changed"
subtitle="Your password has been successfully reset."
footerLinks={onLoginClick ? [
{ label: 'Sign In', onClick: onLoginClick },
] : []}
/>
)
}
return (
<AuthDjangoForm
formName="reset_password_from_key"
onSuccess={() => {
setSuccess(true)
// Give user time to see success message before redirect
if (onComplete) {
setTimeout(onComplete, 2000)
}
}}
footerLinks={onLoginClick ? [
{ href: '#', label: 'Back to Sign In', onClick: onLoginClick },
] : []}
/>
)
}
// ----------------------------------------------------------------------------
// OAuth Error View
// ----------------------------------------------------------------------------
interface OAuthErrorViewProps {
onLoginClick?: () => void
}
function OAuthErrorView({ onLoginClick }: OAuthErrorViewProps) {
const router = useRouter()
const error = router.searchParams.get('error') || 'An error occurred during authentication'
return (
<AuthCard
title="Authentication Failed"
error={error}
footerLinks={onLoginClick ? [
{ label: 'Back to Sign In', onClick: onLoginClick },
] : []}
/>
)
}

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'
}
}

View File

@@ -0,0 +1,85 @@
'use client'
import { ReactNode } from 'react'
import { useRouter } from '../contexts/RouterContext'
import { useStyles } from '../contexts/StylesContext'
interface FooterLink {
label: string
href?: string
onClick?: () => void
}
interface AuthCardProps {
title: string
subtitle?: string
children?: ReactNode
footerLinks?: FooterLink[]
error?: string
success?: string
loading?: boolean
loadingText?: string
}
export function AuthCard({
title,
subtitle,
children,
footerLinks,
error,
success,
loading,
loadingText = 'Loading...',
}: AuthCardProps) {
const router = useRouter()
const styles = useStyles()
const handleLinkClick = (e: React.MouseEvent, href: string) => {
e.preventDefault()
router.push(href)
}
return (
<div className={styles.container}>
<div className={styles.card}>
{loading ? (
<div className={styles.loading}>
<div className={styles.spinner} />
<p className={styles.subtitle}>{loadingText}</p>
</div>
) : (
<>
<h1 className={styles.title}>{title}</h1>
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
{error && <div className={styles.error}>{error}</div>}
{success && <div className={styles.success}>{success}</div>}
{children}
{footerLinks && footerLinks.length > 0 && (
<div className={styles.footer}>
{footerLinks.map((link, i) => (
link.onClick ? (
<button key={i} onClick={link.onClick} className={styles.link}>
{link.label}
</button>
) : link.href ? (
<a
key={i}
href={link.href}
onClick={(e) => handleLinkClick(e, link.href!)}
className={styles.link}
>
{link.label}
</a>
) : null
))}
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,326 @@
'use client'
import { FormEvent, useEffect, useState } from 'react'
import {
useDjangoFormCore,
type DjangoFormState,
type FormOptions,
type FormErrors,
} from 'mizan'
import { useAuthContext } from '../contexts/AuthContext'
import { useStyles } from '../contexts/StylesContext'
import { getAuthDetails, AuthDetails } from '../api'
interface FooterLink {
label: string
href?: string
onClick?: () => void
}
interface AuthDjangoFormProps {
/** Form name (e.g., "login", "signup", "change_password") */
formName: string
/** Callback after successful form submission */
onSuccess?: (result: any, authDetails: AuthDetails) => void
/** Callback after failed form submission */
onError?: (errors: any) => void
/** Links to show in footer (e.g., "Forgot password?") */
footerLinks?: FooterLink[]
/** Content to render before form fields */
preFields?: React.ReactNode
/** Content to render after form fields (before submit button) */
postFields?: React.ReactNode
/** Override the submit button label from schema */
submitLabel?: string
/** Override the title from schema */
title?: string
/** Override the subtitle from schema */
subtitle?: string
/** Options for form behavior (validation, schema refetch, etc.) */
formOptions?: FormOptions
}
/**
* AuthDjangoForm renders a form from the mizan server functions
* with styling consistent with the auth UI.
*
* It fetches the form schema (including title, subtitle, fields, submit label)
* from the backend and renders it dynamically with real-time validation.
*/
export function AuthDjangoForm({
formName,
onSuccess,
onError,
footerLinks,
preFields,
postFields,
submitLabel,
title,
subtitle,
formOptions,
}: AuthDjangoFormProps) {
const form = useDjangoFormCore<Record<string, unknown>>({
name: formName,
options: formOptions,
})
const { refresh } = useAuthContext()
const styles = useStyles()
const [mounted, setMounted] = useState(false)
// Hydration safety: only render inputs after mount
useEffect(() => {
setMounted(true)
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
const result = await form.submit()
if (result.success) {
// Refresh auth state and get the updated auth for callbacks
const newAuth = await refresh()
onSuccess?.(result.data, getAuthDetails(newAuth))
} else {
onError?.(result.errors)
}
}
// Loading state
if (form.loading) {
return (
<div className={styles.container}>
<div className={styles.card}>
<div className={styles.loading}>
<div className={styles.spinner} />
</div>
</div>
</div>
)
}
// Get form-level errors (non-field errors like "Invalid credentials")
// These only appear after submission due to 'field-only' default
const formErrors = form.getFormErrors()
// Use prop overrides or schema values
const displayTitle = title ?? form.schema?.title
const displaySubtitle = subtitle ?? form.schema?.subtitle
const displaySubmitLabel = submitLabel ?? form.schema?.submit_label ?? 'Submit'
return (
<div className={styles.container}>
<div className={styles.card}>
{displayTitle && (
<h1 className={styles.title}>{displayTitle}</h1>
)}
{displaySubtitle && (
<p className={styles.subtitle}>{displaySubtitle}</p>
)}
{/* Form-level errors (shown after submission) */}
{formErrors.length > 0 && (
<div className={styles.error}>
{formErrors.map((err, i) => (
<p key={i}>{err.message}</p>
))}
</div>
)}
<form onSubmit={handleSubmit} className={styles.form}>
{preFields}
<div className={styles.fieldsContainer}>
{form.schema?.fieldOrder.map(fieldName => {
const field = form.schema!.fields[fieldName]
return (
<AuthField
key={fieldName}
field={{
name: fieldName,
label: field.label,
type: field.type,
widget: field.widget,
required: field.required,
disabled: field.disabled,
help_text: field.help_text,
max_length: field.max_length,
choices: field.choices,
}}
value={form.data[fieldName]}
mounted={mounted}
touched={form.touchedFields.has(fieldName)}
errors={form.getFieldErrors(fieldName)}
onChange={(value) => form.set(fieldName, value)}
onBlur={() => form.touch(fieldName)}
/>
)
})}
</div>
{postFields}
<button
type="submit"
disabled={form.submitting || form.validating}
className={styles.submit}
>
{form.submitting ? 'Submitting...' : displaySubmitLabel}
</button>
</form>
{footerLinks && footerLinks.length > 0 && (
<div className={styles.footer}>
{footerLinks.map((link, i) => (
link.onClick ? (
<button key={i} type="button" onClick={link.onClick} className={styles.link}>
{link.label}
</button>
) : link.href ? (
<a key={i} href={link.href} className={styles.link}>
{link.label}
</a>
) : null
))}
</div>
)}
</div>
</div>
)
}
/**
* Internal field component with hydration-safe rendering
*/
interface AuthFieldProps {
field: {
name: string
label: string
type: string
widget: string
required: boolean
disabled: boolean
help_text: string
max_length?: number | null
choices?: Array<{ value: string; label: string }> | null
}
value: any
mounted: boolean
touched: boolean
errors: Array<{ message: string }>
onChange: (value: any) => void
onBlur: () => void
}
function AuthField({ field, value, mounted, touched, errors, onChange, onBlur }: AuthFieldProps) {
const styles = useStyles()
const renderInput = () => {
// Select dropdown
if (field.choices && (field.widget === 'Select' || field.type === 'select')) {
return (
<select
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
required={field.required}
disabled={field.disabled}
className={styles.fieldInput}
>
{field.choices.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
)
}
// Radio buttons
if (field.choices && field.widget === 'RadioSelect') {
return (
<div className={styles.radioGroup}>
{field.choices.map((choice) => (
<label key={choice.value} className={styles.radioItem}>
<input
type="radio"
name={field.name}
value={choice.value}
checked={value === choice.value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
required={field.required}
disabled={field.disabled}
/>
<span>{choice.label}</span>
</label>
))}
</div>
)
}
// Checkbox
if (field.type === 'checkbox') {
return (
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
onBlur={onBlur}
required={field.required}
disabled={field.disabled}
className={styles.checkbox}
/>
)
}
// Textarea
if (field.widget === 'Textarea') {
return (
<textarea
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
required={field.required}
disabled={field.disabled}
maxLength={field.max_length || undefined}
className={styles.fieldInput}
/>
)
}
// Default: text input (text, password, email, etc.)
return (
<input
type={field.type}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
required={field.required}
disabled={field.disabled}
maxLength={field.max_length || undefined}
className={styles.fieldInput}
autoComplete="off"
/>
)
}
return (
<div className={styles.field}>
<label className={styles.fieldLabel}>
{field.label}
</label>
{/* Hydration-safe: render placeholder until mounted */}
{mounted ? (
renderInput()
) : (
<div className={styles.fieldInput} style={{ minHeight: '2.75rem' }} />
)}
{/* Field errors (only show if touched) */}
{touched && errors.map((err, i) => (
<p key={i} className={styles.fieldError}>{err.message}</p>
))}
</div>
)
}

View File

@@ -0,0 +1,99 @@
'use client'
import { ReactNode, useState, useEffect } from 'react'
import { AuthDetails, AuthError, AuthResponse, getAuthDetails } from '../api'
import { useAuthContext } from '../contexts/AuthContext'
import { useStyles } from '../contexts/StylesContext'
interface AuthForm {
submit: () => void
authDetails: AuthDetails
fetching: boolean
response: AuthResponse | null
errors: AuthError[]
}
export default function useAuthForm(
submissionAction: () => Promise<AuthResponse>,
responseAction?: (response: AuthResponse, authDetails: AuthDetails) => void,
): AuthForm {
const auth = useAuthContext().auth
const [fetching, setFetching] = useState<boolean>(false)
const [response, setResponse] = useState<AuthResponse | null>(null)
const [errors, setErrors] = useState<AuthError[]>([])
const [authDetails, setAuthDetails] = useState<AuthDetails>(getAuthDetails(auth))
function submit() {
setFetching(true)
submissionAction()
.then((r) => {
setResponse(r)
setErrors(r.errors || [])
setFetching(false)
if (r && responseAction) {
responseAction(r, authDetails)
}
setAuthDetails(getAuthDetails(auth))
})
.catch((e) => {
console.error(e)
setFetching(false)
})
}
return { submit, authDetails, fetching, response, errors }
}
interface AuthFieldProps {
title: string
name: string
type: string
init: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
authErrors: AuthError[]
placeholder?: string
children?: ReactNode
}
export function AuthField({
title,
name,
type,
init,
onChange,
authErrors,
placeholder,
children,
}: AuthFieldProps) {
const styles = useStyles()
const [mounted, setMounted] = useState(false)
const fieldErrors = authErrors.filter(err => err.param === name)
useEffect(() => {
setMounted(true)
}, [])
return (
<div className={styles.field}>
<label className={styles.fieldLabel}>{title}</label>
{mounted ? (
<input
type={type}
value={init}
onChange={onChange}
placeholder={placeholder}
className={styles.fieldInput}
autoComplete="off"
/>
) : (
<div className={styles.fieldInput} style={{ minHeight: '2.75rem' }} />
)}
{fieldErrors.map((err, i) => (
<p key={i} className={styles.fieldError}>{err.message}</p>
))}
{children}
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import { useState, ReactNode } from 'react'
import { useAllauthAPI } from '../contexts/APIContext'
import { useStyles } from '../contexts/StylesContext'
import useAuthForm, { AuthField } from './AuthForm'
import { AuthResponse, AuthDetails } from '../api'
interface FieldConfig {
name: string
title: string
type: string
placeholder?: string
}
interface FooterLink {
href: string
label: string
}
interface AuthFormPageProps {
title: string
subtitle?: string
fields: FieldConfig[]
submitLabel?: string
submittingLabel?: string
submitFn: (api: ReturnType<typeof useAllauthAPI>, data: Record<string, string>) => Promise<AuthResponse>
onResponse: (response: AuthResponse, authDetails: AuthDetails, data: Record<string, string>) => void
footerLinks?: FooterLink[]
preFields?: ReactNode
postFields?: ReactNode
error?: string | null
}
export function AuthFormPage({
title,
subtitle,
fields,
submitLabel = 'Submit',
submittingLabel = 'Submitting...',
submitFn,
onResponse,
footerLinks,
preFields,
postFields,
error: externalError,
}: AuthFormPageProps) {
const api = useAllauthAPI()
const styles = useStyles()
const [data, setData] = useState<Record<string, string>>(() =>
Object.fromEntries(fields.map(f => [f.name, '']))
)
const authForm = useAuthForm(
() => submitFn(api, data),
(response, authDetails) => onResponse(response, authDetails, data)
)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
authForm.submit()
}
const handleFieldChange = (fieldName: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setData(prev => ({ ...prev, [fieldName]: e.target.value }))
}
const formErrors = authForm.errors.filter(err => !err.param || err.param === '__all__')
return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}>{title}</h1>
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
{externalError && <p className={styles.error}>{externalError}</p>}
{formErrors.length > 0 && (
<div className={styles.error}>
{formErrors.map((err, i) => (
<p key={i}>{err.message}</p>
))}
</div>
)}
<form onSubmit={handleSubmit} className={styles.form} suppressHydrationWarning>
{preFields}
<div className={styles.fieldsContainer}>
{fields.map(field => (
<AuthField
key={field.name}
title={field.title}
name={field.name}
type={field.type}
init={data[field.name]}
onChange={handleFieldChange(field.name)}
authErrors={authForm.errors}
placeholder={field.placeholder}
/>
))}
</div>
{postFields}
<button
type="submit"
className={styles.submit}
disabled={authForm.fetching}
>
{authForm.fetching ? submittingLabel : submitLabel}
</button>
</form>
{footerLinks && footerLinks.length > 0 && (
<div className={styles.footer}>
{footerLinks.map((link, i) => (
<a key={i} href={link.href} className={styles.link}>
{link.label}
</a>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
'use client'
import { useState } from 'react'
import { useRouter } from '../contexts/RouterContext'
import { useConfig } from '../contexts/AuthContext'
import { useAllauthAPI } from '../contexts/APIContext'
import { useAuthContext } from '../contexts/AuthContext'
import { useStyles } from '../contexts/StylesContext'
interface PasskeyLoginProps {
onSuccess?: () => void
}
export function PasskeyLogin({ onSuccess }: PasskeyLoginProps) {
const router = useRouter()
const config = useConfig()
const api = useAllauthAPI()
const { refresh } = useAuthContext()
const styles = useStyles()
const [error, setError] = useState<string | null>(null)
const [authenticating, setAuthenticating] = useState(false)
// Check if passkey login is enabled
const passkeyLoginEnabled = config?.data?.mfa?.passkey_login_enabled
if (!passkeyLoginEnabled) {
return null
}
const handlePasskeyLogin = async () => {
setError(null)
setAuthenticating(true)
try {
const { startAuthentication } = await import('@simplewebauthn/browser')
// Get login options (challenge) from server
const optionsRes = await api.webauthn.requestOptions.login()
if (optionsRes.status !== 200) {
throw new Error('Failed to get login options')
}
// Extract publicKey options - allauth returns { request_options: { publicKey: {...} } }
const publicKeyOptions = optionsRes.data?.request_options?.publicKey
if (!publicKeyOptions?.challenge) {
throw new Error('Invalid login options')
}
// Perform WebAuthn authentication in browser
// @simplewebauthn/browser v13+ expects { optionsJSON: ... }
const credential = await startAuthentication({ optionsJSON: publicKeyOptions as any })
// Submit credential to server for login
const res = await api.webauthn.login(credential)
if (res.status === 200) {
await refresh()
if (onSuccess) {
onSuccess()
} else {
const next = router.searchParams.get('next')
router.push(next?.startsWith('/') ? next : '/dashboard')
}
} else {
setError('Login failed. Please try again.')
}
} catch (e: any) {
if (e.name === 'AbortError' || e.name === 'NotAllowedError') {
// User cancelled - not an error
setError(null)
} else {
setError(e.message || 'Failed to sign in with passkey')
}
} finally {
setAuthenticating(false)
}
}
return (
<div className={styles.passkeyContainer}>
<div className={styles.divider}>
<span className={styles.dividerText}>or</span>
</div>
{error && (
<div className={styles.error}>
<p>{error}</p>
</div>
)}
<button
type="button"
onClick={handlePasskeyLogin}
disabled={authenticating}
className={styles.passkeyButton}
>
{authenticating ? 'Waiting for passkey...' : 'Sign in with Passkey'}
</button>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useConfig } from '../contexts/AuthContext'
import { useAllauthAPI } from '../contexts/APIContext'
import { useStyles } from '../contexts/StylesContext'
interface Provider {
id: string
name: string
}
interface ProviderListProps {
callbackUrl: string
process?: 'login' | 'connect'
}
export function ProviderList({ callbackUrl, process = 'login' }: ProviderListProps) {
const config = useConfig()
const api = useAllauthAPI()
const styles = useStyles()
const providers: Provider[] = config?.data?.socialaccount?.providers || []
if (providers.length === 0) {
return null
}
const handleProviderClick = (providerId: string) => {
const provider = api.oauth.provider(providerId)
if (process === 'connect') {
provider.connect.withRedirect(callbackUrl)
} else {
provider.login.withRedirect(callbackUrl)
}
}
return (
<div className={styles.providersContainer}>
<div className={styles.divider}>
<span className={styles.dividerText}>or continue with</span>
</div>
<div className={styles.providerButtons}>
{providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleProviderClick(provider.id)}
className={styles.providerButton}
>
{provider.name}
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
// Main UI component
export { AllauthUI } from './AllauthUI'
export type { AllauthUIView, AllauthUIMode } from './AllauthUI'
// Core components
export { AuthCard } from './AuthCard'
export { AuthFormPage } from './AuthFormPage'
export { AuthDjangoForm } from './AuthDjangoForm'
export { ProviderList } from './ProviderList'
export { PasskeyLogin } from './PasskeyLogin'
export { default as useAuthForm, AuthField } from './AuthForm'
// Django-initiated flow handler (email verification, password reset links, OAuth)
export { AllauthRouter } from './AllauthRouter'
// Settings components
export {
AuthSettings,
ProfileSection,
EmailsSection,
PasswordSection,
PasskeysSection,
ConnectionsSection,
MFASection,
SessionsSection,
SettingsSection,
SettingsItem,
SettingsList,
Badge,
Button,
} from './settings'
// Individual auth views (for granular control)
export {
LoginView,
SignupView,
MFAChooserView,
MFAWebAuthnView,
MFATOTPView,
MFARecoveryCodesView,
} from './views'

View File

@@ -0,0 +1,79 @@
'use client'
import { useStyles, cx } from '../../contexts/StylesContext'
import { ProfileSection } from './ProfileSection'
import { EmailsSection } from './EmailsSection'
import { PasswordSection } from './PasswordSection'
import { PasskeysSection } from './PasskeysSection'
import { ConnectionsSection } from './ConnectionsSection'
import { MFASection } from './MFASection'
import { SessionsSection } from './SessionsSection'
import { Button } from './SettingsComponents'
type SettingsSectionType = 'profile' | 'emails' | 'password' | 'passkeys' | 'connections' | 'mfa' | 'sessions'
interface AuthSettingsProps {
/** Title shown at the top of the settings page */
title?: string
/** Called when user clicks sign out */
onSignOut?: () => void
/** Which sections to show. Defaults to all. */
sections?: SettingsSectionType[]
/** URL to redirect back to after OAuth connect (for connections section) */
oauthRedirectUrl?: string
}
const DEFAULT_SECTIONS: SettingsSectionType[] = ['profile', 'emails', 'password', 'passkeys', 'connections', 'mfa', 'sessions']
/**
* AuthSettings renders a complete account settings page.
*
* It includes sections for:
* - Profile (display user info)
* - Email addresses (manage, verify, set primary)
* - Password change
* - Passkeys (add/remove passwordless login)
* - Connected accounts (OAuth providers)
* - Two-factor authentication (TOTP, recovery codes)
* - Active sessions (view/end sessions)
*
* @example
* ```tsx
* <AuthSettings
* onSignOut={() => router.push('/logout')}
* sections={['profile', 'password', 'mfa']} // Only show these sections
* />
* ```
*/
export function AuthSettings({
title = 'Account Settings',
onSignOut,
sections = DEFAULT_SECTIONS,
oauthRedirectUrl,
}: AuthSettingsProps) {
const styles = useStyles()
const sectionSet = new Set(sections)
return (
<div className={styles.settingsContainer}>
<h1 className={styles.settingsPageTitle}>{title}</h1>
{sectionSet.has('profile') && <ProfileSection />}
{sectionSet.has('emails') && <EmailsSection />}
{sectionSet.has('password') && <PasswordSection />}
{sectionSet.has('passkeys') && <PasskeysSection />}
{sectionSet.has('connections') && <ConnectionsSection redirectUrl={oauthRedirectUrl} />}
{sectionSet.has('mfa') && <MFASection />}
{sectionSet.has('sessions') && <SessionsSection />}
{/* Sign Out */}
{onSignOut && (
<section className={styles.settingsCard}>
<Button variant="danger" onClick={onSignOut}>
Sign Out
</Button>
</section>
)}
</div>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import { useState, useEffect } from 'react'
import { useConfig } from '../../contexts/AuthContext'
import { useAllauthAPI } from '../../contexts/APIContext'
import { SettingsSection, SettingsItem, SettingsList, Button } from './SettingsComponents'
interface Connection {
uid: string
provider: { id: string; name: string }
display: string
}
interface ConnectionsSectionProps {
/** URL to redirect back to after OAuth connect */
redirectUrl?: string
}
export function ConnectionsSection({ redirectUrl = '/account' }: ConnectionsSectionProps) {
const api = useAllauthAPI()
const config = useConfig()
const [connections, setConnections] = useState<Connection[]>([])
const [loading, setLoading] = useState(true)
const availableProviders = config?.data?.socialaccount?.providers || []
const fetchConnections = async () => {
const res = await api.oauth.list()
if (res.status === 200 && res.data) {
setConnections(res.data)
}
setLoading(false)
}
useEffect(() => { fetchConnections() }, [])
const handleConnect = (providerId: string) => {
api.oauth.provider(providerId).connect.withRedirect(redirectUrl)
}
const handleDisconnect = async (providerId: string, uid: string) => {
if (!confirm('Disconnect this account?')) return
await api.oauth.provider(providerId).removeFrom(uid)
fetchConnections()
}
// Don't render if no providers configured or still loading
if (loading) return null
const connectedProviderIds = connections.map(c => c.provider.id)
const unconnectedProviders = availableProviders.filter(
(p: { id: string }) => !connectedProviderIds.includes(p.id)
)
// Hide section entirely if no social providers
if (connections.length === 0 && availableProviders.length === 0) return null
return (
<SettingsSection title="Connected Accounts">
<SettingsList>
{connections.map(conn => (
<SettingsItem
key={conn.uid}
label={conn.provider.name}
meta={conn.display}
actions={
<Button variant="danger" onClick={() => handleDisconnect(conn.provider.id, conn.uid)}>
Disconnect
</Button>
}
/>
))}
{unconnectedProviders.map((provider: { id: string; name: string }) => (
<SettingsItem
key={provider.id}
label={provider.name}
actions={
<Button onClick={() => handleConnect(provider.id)}>
Connect
</Button>
}
/>
))}
</SettingsList>
</SettingsSection>
)
}

View File

@@ -0,0 +1,120 @@
'use client'
import { useState, useEffect } from 'react'
import { useAllauthAPI } from '../../contexts/APIContext'
import { useStyles } from '../../contexts/StylesContext'
import { useDjangoFormCore } from 'mizan'
import { SettingsSection, SettingsItem, SettingsList, Badge, Button } from './SettingsComponents'
interface Email {
email: string
primary: boolean
verified: boolean
}
export function EmailsSection() {
const api = useAllauthAPI()
const styles = useStyles()
const [emails, setEmails] = useState<Email[]>([])
const [loading, setLoading] = useState(true)
const addEmailForm = useDjangoFormCore<Record<string, unknown>>({ name: 'add_email' })
const fetchEmails = async () => {
const res = await api.account.emails.list()
if (res.status === 200 && res.data) {
setEmails(res.data)
}
setLoading(false)
}
useEffect(() => { fetchEmails() }, [])
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
const result = await addEmailForm.submit()
if (result.success) {
addEmailForm.reset()
fetchEmails()
}
}
const handleRemove = async (email: string) => {
if (!confirm(`Remove ${email}?`)) return
await api.account.emails.remove(email)
fetchEmails()
}
const handleSetPrimary = async (email: string) => {
await api.account.emails.setPrimary(email)
fetchEmails()
}
const handleResendVerification = async (email: string) => {
await api.account.emails.verification.dispatch(email)
alert('Verification email sent!')
}
if (loading) return null
return (
<SettingsSection title="Email Addresses">
<SettingsList>
{emails.map(email => (
<SettingsItem
key={email.email}
label={
<>
{email.email}
{email.primary && <Badge variant="primary">Primary</Badge>}
{!email.verified && <Badge variant="warning">Unverified</Badge>}
</>
}
actions={
<>
{!email.verified && (
<Button variant="secondary" onClick={() => handleResendVerification(email.email)}>
Verify
</Button>
)}
{!email.primary && email.verified && (
<Button onClick={() => handleSetPrimary(email.email)}>
Make Primary
</Button>
)}
{!email.primary && (
<Button variant="danger" onClick={() => handleRemove(email.email)}>
Remove
</Button>
)}
</>
}
/>
))}
</SettingsList>
{!addEmailForm.loading && (
<form onSubmit={handleAdd} className={styles.inlineForm}>
<div className={styles.field}>
<label className={styles.fieldLabel}>
{addEmailForm.schema?.fields.email?.label || 'Add Email'}
</label>
<input
type="email"
value={(addEmailForm.data.email as string) || ''}
onChange={(e) => addEmailForm.set('email', e.target.value)}
onBlur={() => addEmailForm.touch('email')}
className={styles.fieldInput}
required
/>
{addEmailForm.getFieldErrors('email').map((err, i) => (
<p key={i} className={styles.fieldError}>{err.message}</p>
))}
</div>
<Button type="submit">
{addEmailForm.schema?.submit_label || 'Add'}
</Button>
</form>
)}
</SettingsSection>
)
}

View File

@@ -0,0 +1,171 @@
'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>
)}
</>
)
}

View File

@@ -0,0 +1,103 @@
'use client'
import { useState, useEffect } from 'react'
import { useAllauthAPI } from '../../contexts/APIContext'
import { useConfig } from '../../contexts/AuthContext'
import { useStyles } from '../../contexts/StylesContext'
import { SettingsSection, SettingsItem, SettingsList, Button } from './SettingsComponents'
import type { Authenticator, WebAuthnAuthenticator } from '../../types'
export function PasskeysSection() {
const api = useAllauthAPI()
const config = useConfig()
const styles = useStyles()
const [passkeys, setPasskeys] = useState<WebAuthnAuthenticator[]>([])
const [loading, setLoading] = useState(true)
// Check if passkey login is enabled
const passkeyLoginEnabled = config?.data?.mfa?.passkey_login_enabled
const fetchPasskeys = async () => {
try {
const res = await api.mfa.list()
if (res.status === 200 && res.data) {
const authenticators = res.data as Authenticator[]
setPasskeys(authenticators.filter((a): a is WebAuthnAuthenticator => a.type === 'webauthn'))
}
} catch {
// Silently fail - passkeys just won't show
}
setLoading(false)
}
useEffect(() => { fetchPasskeys() }, [])
// Don't render if passkey login isn't enabled
if (!passkeyLoginEnabled) return null
if (loading) return null
const handleAdd = async () => {
try {
const { startRegistration } = await import('@simplewebauthn/browser')
// Request creation options - use passwordless=true for login passkeys
const optionsRes = await api.webauthn.requestOptions.creation(true)
if (optionsRes.status !== 200) {
return
}
const publicKeyOptions = optionsRes.data?.creation_options?.publicKey
if (!publicKeyOptions) throw new Error('Invalid options response')
// @simplewebauthn/browser v13+ expects { optionsJSON: ... }
const credential = await startRegistration({ optionsJSON: publicKeyOptions as any })
const name = prompt('Name this passkey:') || 'Passkey'
const res = await api.webauthn.add(name, credential)
if (res.status === 200) {
fetchPasskeys()
}
} catch (e: any) {
if (e.name !== 'AbortError') {
alert(e.message || 'Failed to add passkey')
}
}
}
const handleRemove = async (id: number) => {
if (!confirm('Remove this passkey? You won\'t be able to use it to sign in anymore.')) return
await api.webauthn.delete([id])
fetchPasskeys()
}
return (
<SettingsSection title="Passkeys">
<p className={styles.settingsItemMeta} style={{ marginBottom: '1rem' }}>
Passkeys let you sign in quickly using your device's biometrics or security key.
No password needed.
</p>
{passkeys.length > 0 && (
<SettingsList>
{passkeys.map(passkey => (
<SettingsItem
key={passkey.id}
label={passkey.name}
meta={`Added ${new Date(passkey.created_at * 1000).toLocaleDateString()}`}
actions={
<Button variant="danger" onClick={() => handleRemove(passkey.id)}>
Remove
</Button>
}
/>
))}
</SettingsList>
)}
<Button onClick={handleAdd}>
{passkeys.length > 0 ? 'Add Another Passkey' : 'Set Up Passkey'}
</Button>
</SettingsSection>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
import { useDjangoFormCore } from 'mizan'
import { useStyles } from '../../contexts/StylesContext'
import { SettingsSection, Button } from './SettingsComponents'
export function PasswordSection() {
const styles = useStyles()
const form = useDjangoFormCore<Record<string, unknown>>({ name: 'change_password' })
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const result = await form.submit()
if (result.success) {
form.reset()
alert('Password changed successfully!')
}
}
if (form.loading) return null
return (
<SettingsSection title={form.schema?.title || 'Change Password'}>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.fieldsContainer}>
{form.schema?.fieldOrder.map(fieldName => {
const field = form.schema!.fields[fieldName]
return (
<div key={fieldName} className={styles.field}>
<label className={styles.fieldLabel}>{field.label}</label>
<input
type={field.type}
value={(form.data[fieldName] as string) || ''}
onChange={(e) => form.set(fieldName, e.target.value)}
onBlur={() => form.touch(fieldName)}
className={styles.fieldInput}
required={field.required}
/>
{form.touchedFields.has(fieldName) &&
form.getFieldErrors(fieldName).map((err, i) => (
<p key={i} className={styles.fieldError}>{err.message}</p>
))
}
</div>
)
})}
</div>
<Button type="submit" disabled={form.submitting}>
{form.submitting ? 'Changing...' : (form.schema?.submit_label || 'Change Password')}
</Button>
</form>
</SettingsSection>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
import { useUser } from '../../contexts/AuthContext'
import { SettingsSection, SettingsItem, SettingsList } from './SettingsComponents'
export function ProfileSection() {
const user = useUser()
return (
<SettingsSection title="Profile">
<SettingsList>
<SettingsItem label="Email" meta={user?.email} />
{user?.first_name && (
<SettingsItem
label="Name"
meta={`${user.first_name} ${user.last_name || ''}`.trim()}
/>
)}
</SettingsList>
</SettingsSection>
)
}

View File

@@ -0,0 +1,88 @@
'use client'
import { useState, useEffect } from 'react'
import { useAllauthAPI } from '../../contexts/APIContext'
import { SettingsSection, SettingsItem, SettingsList, Badge, Button } from './SettingsComponents'
import type { Session } from '../../types'
function parseUserAgent(ua: string): string {
if (ua.includes('Chrome')) return 'Chrome'
if (ua.includes('Firefox')) return 'Firefox'
if (ua.includes('Safari')) return 'Safari'
if (ua.includes('Edge')) return 'Edge'
return 'Unknown Browser'
}
export function SessionsSection() {
const api = useAllauthAPI()
const [sessions, setSessions] = useState<Session[]>([])
const [loading, setLoading] = useState(true)
const [available, setAvailable] = useState(true)
const fetchSessions = async () => {
try {
const res = await api.session.list()
if (res.status === 200 && res.data) {
setSessions(res.data as Session[])
} else {
// Non-200 status means sessions feature not available
setAvailable(false)
}
} catch {
setAvailable(false)
}
setLoading(false)
}
useEffect(() => { fetchSessions() }, [])
const handleEnd = async (id: number) => {
if (!confirm('End this session?')) return
await api.session.remove([id])
fetchSessions()
}
const handleEndAllOthers = async () => {
const otherIds = sessions.filter(s => !s.is_current).map(s => s.id)
if (otherIds.length === 0) return
if (!confirm(`End ${otherIds.length} other session(s)?`)) return
await api.session.remove(otherIds)
fetchSessions()
}
if (loading || !available) return null
const otherSessions = sessions.filter(s => !s.is_current)
return (
<SettingsSection title="Active Sessions">
<SettingsList>
{sessions.map(session => (
<SettingsItem
key={session.id}
label={
<>
{parseUserAgent(session.user_agent)}
{session.is_current && <Badge variant="success">Current</Badge>}
</>
}
meta={`${session.ip} · ${session.last_seen_at ? new Date(session.last_seen_at * 1000).toLocaleString() : 'Unknown'}`}
actions={
!session.is_current && (
<Button variant="danger" onClick={() => handleEnd(session.id)}>
End
</Button>
)
}
/>
))}
</SettingsList>
{otherSessions.length > 0 && (
<Button variant="danger" onClick={handleEndAllOthers}>
End All Other Sessions
</Button>
)}
</SettingsSection>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { useStyles, cx } from '../../contexts/StylesContext'
interface SettingsSectionProps {
title: string
children: React.ReactNode
}
export function SettingsSection({ title, children }: SettingsSectionProps) {
const styles = useStyles()
return (
<section className={styles.settingsCard}>
<h2 className={styles.settingsSectionTitle}>{title}</h2>
{children}
</section>
)
}
interface SettingsItemProps {
label: React.ReactNode
meta?: React.ReactNode
actions?: React.ReactNode
}
export function SettingsItem({ label, meta, actions }: SettingsItemProps) {
const styles = useStyles()
return (
<div className={styles.settingsItem}>
<div className={styles.settingsItemInfo}>
<span className={styles.settingsItemLabel}>{label}</span>
{meta && <span className={styles.settingsItemMeta}>{meta}</span>}
</div>
{actions && <div className={styles.settingsItemActions}>{actions}</div>}
</div>
)
}
export function SettingsList({ children }: { children: React.ReactNode }) {
const styles = useStyles()
return <div className={styles.settingsList}>{children}</div>
}
type BadgeVariant = 'primary' | 'success' | 'warning' | 'danger'
export function Badge({ variant, children }: { variant: BadgeVariant, children: React.ReactNode }) {
const styles = useStyles()
const variantClass = {
primary: styles.badgePrimary,
success: styles.badgeSuccess,
warning: styles.badgeUnverified,
danger: styles.badgeDanger,
}[variant]
return <span className={cx(styles.badge, variantClass)}>{children}</span>
}
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'small' | 'normal'
}
export function Button({ variant = 'primary', size = 'small', className, children, ...props }: ButtonProps) {
const styles = useStyles()
const variantClass = {
primary: styles.smallButtonPrimary,
secondary: styles.smallButtonSecondary,
danger: styles.smallButtonDanger,
}[variant]
return (
<button className={cx(styles.smallButton, variantClass, className)} {...props}>
{children}
</button>
)
}

View File

@@ -0,0 +1,20 @@
// Main settings component
export { AuthSettings } from './AuthSettings'
// Individual sections (for custom layouts)
export { ProfileSection } from './ProfileSection'
export { EmailsSection } from './EmailsSection'
export { PasswordSection } from './PasswordSection'
export { PasskeysSection } from './PasskeysSection'
export { ConnectionsSection } from './ConnectionsSection'
export { MFASection } from './MFASection'
export { SessionsSection } from './SessionsSection'
// Building blocks (for custom components)
export {
SettingsSection,
SettingsItem,
SettingsList,
Badge,
Button,
} from './SettingsComponents'

View File

@@ -0,0 +1,75 @@
'use client'
import { useAuthContext, useConfig } from '../../contexts/AuthContext'
import { getAuthDetails } from '../../api'
import { AuthDjangoForm } from '../AuthDjangoForm'
import { PasskeyLogin } from '../PasskeyLogin'
import { ProviderList } from '../ProviderList'
import type { AllauthConfiguration } from '../../types'
interface LoginViewProps {
/** Called after successful login (or when MFA is triggered) */
onSuccess?: () => void
/** Called when user clicks "Create account" */
onSignupClick?: () => void
/** Called when user clicks "Forgot password" */
onForgotPasswordClick?: () => void
/** Called when user clicks "Sign in with code" */
onLoginByCodeClick?: () => void
/** OAuth callback URL for social providers */
oauthCallbackUrl?: string
}
export function LoginView({
onSuccess,
onSignupClick,
onForgotPasswordClick,
onLoginByCodeClick,
oauthCallbackUrl,
}: LoginViewProps) {
const { refresh } = useAuthContext()
const config = useConfig()
// Get feature flags from backend config
const allauthConfig = config?.data as AllauthConfiguration | undefined
const isSignupEnabled = allauthConfig?.account?.is_open_for_signup ?? true
const isLoginByCodeEnabled = allauthConfig?.account?.login_by_code_enabled ?? false
const handleSuccess = async () => {
const newAuth = await refresh()
const details = getAuthDetails(newAuth)
// Only call onSuccess if fully authenticated (no pending MFA)
// If MFA is pending, AllauthUI will handle showing the MFA view
if (details.isAuthenticated) {
onSuccess?.()
}
}
// Build footer links based on provided callbacks AND backend config
const footerLinks: Array<{ href?: string; label: string; onClick?: () => void }> = []
if (onForgotPasswordClick) {
footerLinks.push({ label: 'Forgot your password?', onClick: onForgotPasswordClick })
}
if (onLoginByCodeClick && isLoginByCodeEnabled) {
footerLinks.push({ label: 'Sign in with a code instead', onClick: onLoginByCodeClick })
}
if (onSignupClick && isSignupEnabled) {
footerLinks.push({ label: "Don't have an account? Sign up", onClick: onSignupClick })
}
return (
<AuthDjangoForm
formName="login"
onSuccess={handleSuccess}
footerLinks={footerLinks}
postFields={
<>
<PasskeyLogin onSuccess={onSuccess} />
{oauthCallbackUrl && <ProviderList callbackUrl={oauthCallbackUrl} />}
</>
}
/>
)
}

View File

@@ -0,0 +1,137 @@
'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>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import { useState } from 'react'
import { useAllauthAPI } from '../../contexts/APIContext'
import { AuthDjangoForm } from '../AuthDjangoForm'
interface MFARecoveryCodesViewProps {
onSuccess?: () => void
onCancel?: () => void
onBack?: () => void
isReauth?: boolean
}
export function MFARecoveryCodesView({ onSuccess, onCancel, onBack, isReauth }: MFARecoveryCodesViewProps) {
const api = useAllauthAPI()
const [cancelling, setCancelling] = useState(false)
const handleCancel = async () => {
setCancelling(true)
try {
await api.session.logout()
onCancel?.()
} catch {
setCancelling(false)
}
}
// Build footer links
const footerLinks = []
if (onBack) {
footerLinks.push({ label: 'Use a different method', onClick: onBack })
}
if (onCancel) {
footerLinks.push({
label: cancelling ? 'Cancelling...' : 'Cancel',
onClick: handleCancel
})
}
const formName = isReauth ? 'mfa_reauthenticate' : 'mfa_authenticate'
return (
<AuthDjangoForm
formName={formName}
title="Recovery Code"
subtitle="Enter one of your recovery codes."
onSuccess={() => onSuccess?.()}
footerLinks={footerLinks}
/>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import { useState } from 'react'
import { useAllauthAPI } from '../../contexts/APIContext'
import { AuthDjangoForm } from '../AuthDjangoForm'
interface MFATOTPViewProps {
onSuccess?: () => void
onCancel?: () => void
onBack?: () => void
isReauth?: boolean
}
export function MFATOTPView({ onSuccess, onCancel, onBack, isReauth }: MFATOTPViewProps) {
const api = useAllauthAPI()
const [cancelling, setCancelling] = useState(false)
const handleCancel = async () => {
setCancelling(true)
try {
await api.session.logout()
onCancel?.()
} catch {
setCancelling(false)
}
}
// Build footer links
const footerLinks = []
if (onBack) {
footerLinks.push({ label: 'Use a different method', onClick: onBack })
}
if (onCancel) {
footerLinks.push({
label: cancelling ? 'Cancelling...' : 'Cancel',
onClick: handleCancel
})
}
const formName = isReauth ? 'mfa_reauthenticate' : 'mfa_authenticate'
return (
<AuthDjangoForm
formName={formName}
title="Authenticator App"
subtitle="Enter the 6-digit code from your authenticator app."
onSuccess={() => onSuccess?.()}
footerLinks={footerLinks}
/>
)
}

View File

@@ -0,0 +1,113 @@
'use client'
import { useState } from 'react'
import { useAllauthAPI } from '../../contexts/APIContext'
import { useAuthContext } from '../../contexts/AuthContext'
import { useStyles } from '../../contexts/StylesContext'
interface MFAWebAuthnViewProps {
onSuccess?: () => void
onCancel?: () => void
onBack?: () => void
isReauth?: boolean
}
export function MFAWebAuthnView({ onSuccess, onCancel, onBack, isReauth }: MFAWebAuthnViewProps) {
const api = useAllauthAPI()
const { refresh } = useAuthContext()
const styles = useStyles()
const [error, setError] = useState<string | null>(null)
const [authenticating, setAuthenticating] = useState(false)
const [cancelling, setCancelling] = useState(false)
const handleCancel = async () => {
setCancelling(true)
try {
await api.session.logout()
onCancel?.()
} catch {
setCancelling(false)
}
}
const handleWebAuthn = async () => {
setError(null)
setAuthenticating(true)
try {
const { startAuthentication } = await import('@simplewebauthn/browser')
// Get challenge from server
const optionsRes = isReauth
? await api.webauthn.requestOptions.reauthentication()
: await api.webauthn.requestOptions.authentication()
if (optionsRes.status !== 200 || !optionsRes.data?.request_options?.publicKey) {
throw new Error('Failed to get authentication options')
}
// Perform WebAuthn authentication
// The allauth API returns { request_options: { publicKey: {...} } }
// @simplewebauthn/browser v13+ expects { optionsJSON: ... }
const credential = await startAuthentication({ optionsJSON: optionsRes.data.request_options.publicKey as any })
// Verify with server
const res = isReauth
? await api.webauthn.reauthenticate(credential)
: await api.webauthn.authenticate(credential)
if (res.status === 200) {
await refresh()
onSuccess?.()
} else {
setError('Authentication failed. Please try again.')
}
} catch (e: any) {
if (e.name === 'AbortError' || e.name === 'NotAllowedError') {
setError(null)
} else {
setError(e.message || 'Failed to authenticate with security key')
}
} finally {
setAuthenticating(false)
}
}
return (
<div className={styles.container}>
<div className={styles.card}>
<h1 className={styles.title}>Security Key</h1>
<p className={styles.subtitle}>Use your security key to verify your identity.</p>
{error && (
<div className={styles.error}>
<p>{error}</p>
</div>
)}
<div className={styles.form}>
<button
onClick={handleWebAuthn}
disabled={authenticating}
className={styles.submit}
>
{authenticating ? 'Waiting for security key...' : 'Use Security Key'}
</button>
</div>
<div className={styles.footer}>
{onBack && (
<button onClick={onBack} className={styles.link}>
Use a different method
</button>
)}
{onCancel && (
<button onClick={handleCancel} disabled={cancelling} className={styles.link}>
{cancelling ? 'Cancelling...' : 'Cancel'}
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import { useAuthContext } from '../../contexts/AuthContext'
import { getAuthDetails } from '../../api'
import { AuthDjangoForm } from '../AuthDjangoForm'
interface SignupViewProps {
/** Called after successful signup */
onSuccess?: () => void
/** Called when user clicks "Already have an account? Sign in" */
onLoginClick?: () => void
}
export function SignupView({
onSuccess,
onLoginClick,
}: SignupViewProps) {
const { refresh } = useAuthContext()
const handleSuccess = async () => {
const newAuth = await refresh()
const details = getAuthDetails(newAuth)
if (details.isAuthenticated) {
onSuccess?.()
}
}
const footerLinks: Array<{ label: string; onClick?: () => void }> = []
if (onLoginClick) {
footerLinks.push({ label: 'Already have an account? Sign in', onClick: onLoginClick })
}
return (
<AuthDjangoForm
formName="signup"
onSuccess={handleSuccess}
footerLinks={footerLinks}
/>
)
}

View File

@@ -0,0 +1,6 @@
export { LoginView } from './LoginView'
export { SignupView } from './SignupView'
export { MFAChooserView } from './MFAChooserView'
export { MFAWebAuthnView } from './MFAWebAuthnView'
export { MFATOTPView } from './MFATOTPView'
export { MFARecoveryCodesView } from './MFARecoveryCodesView'