Files
mizan/legacy/allauth/components/AuthDjangoForm.tsx
Ryth Azhur 27c30d7e50 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>
2026-04-07 03:41:22 -04:00

327 lines
11 KiB
TypeScript

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