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,72 @@
'use client'
import { useMemo } from 'react'
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
import { useAuthContext } from './AuthContext'
import { createAPI, AllauthAPI, BrowserFormAction } from '../api'
/**
* Browser form action for OAuth redirects.
* Creates and submits a form programmatically.
*/
const browserFormAction: BrowserFormAction = (action: string, data: Record<string, string>) => {
const form = document.createElement('form')
form.method = 'POST'
form.action = action
for (const [key, value] of Object.entries(data)) {
const input = document.createElement('input')
input.type = 'hidden'
input.name = key
input.value = value
form.appendChild(input)
}
document.body.appendChild(form)
form.submit()
}
/**
* Hook that returns the Allauth API with automatic auth refresh on relevant responses.
*
* Automatically triggers auth refresh when:
* - 401 with flows (authentication required)
* - 410 (session gone)
* - 200 with is_authenticated (successful auth)
*/
export function useAllauthAPI(): AllauthAPI {
const client = useDjangoCSRClient(Auth.SESSION)
const { refresh } = useAuthContext()
return useMemo(() => {
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
const resp = await client.request(method, `/_allauth/browser/v1${path}`, data, headers)
if (resp.status >= 500) {
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
}
try {
return await resp.json()
} catch {
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
}
}
return createAPI(
async (method, path, data?, headers?) => {
const resp = await authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
// Auto-refresh auth state on relevant responses
if (resp.status === 401 && resp.data?.flows) {
refresh(resp)
} else if ([401, 410].includes(resp.status) || (resp.status === 200 && resp.meta?.is_authenticated)) {
refresh()
}
return resp
},
browserFormAction
)
}, [client, refresh])
}

View File

@@ -0,0 +1,116 @@
'use client'
import { ReactNode, useEffect, useState } from 'react'
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
import type { RouterAdapter } from '../adapters/router'
import type { InitialAuth } from '../hydration'
import { AuthContext } from './AuthContext'
import { ConfigContext } from './ConfigContext'
import { StylesContext } from './StylesContext'
import { RouterContext } from './RouterContext'
import { AllauthConfig } from '../config'
import { AuthClassNames } from '../styles/types'
import { createAPI } from '../api'
export interface AllauthContextProps {
children: ReactNode
/** Router adapter for navigation */
router: RouterAdapter
/** Optional initial auth state from getInitialAuth() - if not provided, fetches client-side */
hydration?: InitialAuth
/** Library configuration (basePath, routes) */
allauthConfig?: Partial<AllauthConfig>
/** CSS class names for styling components */
classNames?: AuthClassNames
}
/**
* Core AllauthContext - sets up all contexts for the allauth library.
*
* IMPORTANT: AllauthContext must be wrapped by DjangoContext, which provides
* user data via useUser(). The typical setup is:
*
* ```tsx
* <DjangoContext client={client} hydration={djangoHydration}>
* <AllauthContext hydration={allauthHydration}>
* {children}
* </AllauthContext>
* </DjangoContext>
* ```
*
* If hydration is provided (from SSR), uses it immediately.
* If not provided, fetches initial auth state client-side using the CSR client.
*
* For Next.js apps, use NextAllauthContext instead which handles the router automatically.
*/
export function AllauthContext({
children,
router,
hydration,
allauthConfig,
classNames,
}: AllauthContextProps) {
const client = useDjangoCSRClient(Auth.SESSION)
const [initialAuth, setInitialAuth] = useState<InitialAuth | null>(hydration ?? null)
const [loading, setLoading] = useState(!hydration)
useEffect(() => {
if (hydration) return // Already have SSR hydration
const fetchInitialAuth = async () => {
try {
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
const resp = await client.request(method, `/_allauth/browser/v1${path}`, data, headers)
if (resp.status >= 500) {
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
}
return resp.json()
}
const api = createAPI((method, path, data?, headers?) =>
authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
)
const [config, auth] = await Promise.all([
api.getConfig(),
api.session.getStatus(),
])
setInitialAuth({ config, auth })
} catch (e) {
console.error('[AllauthContext] Failed to fetch initial auth:', e)
setInitialAuth({
config: { status: 200, data: {} },
auth: { status: 401, data: {} },
})
} finally {
setLoading(false)
}
}
fetchInitialAuth()
}, [client, hydration])
if (loading || !initialAuth) {
return null
}
return (
<RouterContext router={router}>
<ConfigContext config={allauthConfig}>
<StylesContext classNames={classNames}>
<AuthContext
config={initialAuth.config}
auth={initialAuth.auth}
>
{children}
</AuthContext>
</StylesContext>
</ConfigContext>
</RouterContext>
)
}

View File

@@ -0,0 +1,153 @@
'use client'
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
import { useMizan, useMizanContext } from 'mizan'
import { getAuthDetails, createAPI } from '../api'
import type { AllauthResponse } from '../types'
import getAuthChangeEvent from '../events'
export interface AuthState {
config: AllauthResponse
auth: AllauthResponse
event: string
refresh: (newAuth?: AllauthResponse) => Promise<AllauthResponse>
}
const Context = createContext<AuthState | null>(null)
export interface AuthContextProps {
children: ReactNode
/** Initial config from hydration */
config: AllauthResponse
/** Initial auth from hydration */
auth: AllauthResponse
}
export function AuthContext({
children,
config,
auth: initialAuth,
}: AuthContextProps) {
const client = useDjangoCSRClient(Auth.SESSION)
const { refreshAllContexts } = useMizan()
const [auth, setAuth] = useState(initialAuth)
const [event, setEvent] = useState('')
const prevAuth = useRef(initialAuth)
// Create API for refresh operations
const baseAPI = useMemo(() => {
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
const resp = await client.request(method, `/_allauth/browser/v1${path}`, data, headers)
if (resp.status >= 500) {
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
}
return resp.json()
}
return createAPI((method, path, data?, headers?) =>
authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
)
}, [client])
const refresh = useCallback(async (newAuth?: AllauthResponse): Promise<AllauthResponse> => {
const authState = newAuth ?? await baseAPI.session.getStatus()
setAuth(authState)
// Refresh all Django contexts (user data, permissions, etc.)
await refreshAllContexts()
return authState
}, [baseAPI, refreshAllContexts])
useEffect(() => {
if (prevAuth.current && auth) {
setEvent(getAuthChangeEvent(prevAuth.current, auth))
}
prevAuth.current = auth
}, [auth])
const contextValue = useMemo(() => ({
config, auth, event, refresh
}), [config, auth, event, refresh])
return (
<Context value={contextValue}>
{children}
</Context>
)
}
export function useAuthContext(): AuthState {
const ctx = useContext(Context)
if (!ctx) throw new Error('useAuthContext must be used within AuthContext')
return ctx
}
export function useAuth() {
return getAuthDetails(useAuthContext().auth)
}
/**
* Base user interface expected by Allauth.
* Products can extend this with additional fields.
*/
export interface AllauthUser {
email?: string
first_name?: string
last_name?: string
is_staff?: boolean
is_superuser?: boolean
}
/**
* Get the current user from MizanProvider.
*
* This uses the generic mizan hook to access the 'user' context.
* The backend defines this context in lib/mizan/allauth/contexts.py:
*
* @client(context='global')
* def user(request) -> UserOutput | None:
* ...
*
* @typeParam T - User type (defaults to AllauthUser, products can use more specific types)
*/
export function useUser<T extends AllauthUser = AllauthUser>(): T {
const user = useMizanContext<T>('user')
// Return empty object cast to T if user is undefined (not loaded)
// This matches the previous behavior and allows optional chaining
return (user ?? {}) as T
}
export function useConfig() {
return useAuthContext().config
}
/**
* Hook to access backend feature flags from the allauth configuration.
*/
export function useFeatures() {
const config = useConfig()
const data = config?.data as {
account?: {
is_open_for_signup?: boolean
login_by_code_enabled?: boolean
email_verification_by_code_enabled?: boolean
}
mfa?: {
supported_types?: string[]
}
socialaccount?: {
providers?: any[]
}
} | undefined
return {
signupEnabled: data?.account?.is_open_for_signup ?? true,
loginByCodeEnabled: data?.account?.login_by_code_enabled ?? false,
emailVerificationByCodeEnabled: data?.account?.email_verification_by_code_enabled ?? false,
mfaEnabled: (data?.mfa?.supported_types?.length ?? 0) > 0,
mfaTypes: data?.mfa?.supported_types ?? [],
socialLoginEnabled: (data?.socialaccount?.providers?.length ?? 0) > 0,
socialProviders: data?.socialaccount?.providers ?? [],
}
}

View File

@@ -0,0 +1,29 @@
'use client'
import { createContext, ReactNode, useContext, useMemo } from 'react'
import { AllauthConfig, defaultConfig, createAllauthConfig } from '../config'
const Context = createContext<AllauthConfig>(defaultConfig)
interface ConfigContextProps {
children: ReactNode
config?: Partial<AllauthConfig>
}
export function ConfigContext({ children, config }: ConfigContextProps) {
// Memoize the merged config to prevent creating new objects on every render
const mergedConfig = useMemo(
() => config ? createAllauthConfig(config) : defaultConfig,
[config]
)
return (
<Context value={mergedConfig}>
{children}
</Context>
)
}
export function useAllauthConfig(): AllauthConfig {
return useContext(Context)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { createContext, useContext, type ReactNode } from 'react'
import type { RouterAdapter } from '../adapters/router'
const Context = createContext<RouterAdapter | null>(null)
interface RouterContextProps {
children: ReactNode
router: RouterAdapter
}
export function RouterContext({ children, router }: RouterContextProps) {
return (
<Context value={router}>
{children}
</Context>
)
}
/**
* Hook to access the router adapter.
* Must be used within AllauthContext.
*/
export function useRouter(): RouterAdapter {
const router = useContext(Context)
if (!router) {
throw new Error('useRouter must be used within AllauthContext')
}
return router
}

View File

@@ -0,0 +1,49 @@
'use client'
import { createContext, useContext, ReactNode } from 'react'
import { AuthClassNames, emptyClassNames } from '../styles/types'
const Context = createContext<AuthClassNames>(emptyClassNames)
interface StylesContextProps {
children: ReactNode
classNames?: AuthClassNames
}
export function StylesContext({ children, classNames }: StylesContextProps) {
return (
<Context value={classNames ?? emptyClassNames}>
{children}
</Context>
)
}
/**
* Hook to access auth component class names.
*
* Returns the class names provided to AllauthProvider, or empty strings if none provided.
* Use this to style custom components consistently with the auth UI.
*
* @example
* ```tsx
* function MyAuthComponent() {
* const styles = useStyles()
* return (
* <div className={styles.card}>
* <h1 className={styles.title}>Custom Auth View</h1>
* </div>
* )
* }
* ```
*/
export function useStyles(): AuthClassNames {
return useContext(Context)
}
/**
* Utility to get a class name, returning empty string if undefined.
* Useful for conditional class application.
*/
export function cx(...classNames: (string | undefined | false | null)[]): string {
return classNames.filter(Boolean).join(' ')
}

View File

@@ -0,0 +1,6 @@
export { AllauthContext, type AllauthContextProps } from './AllauthContext'
export { AuthContext, useAuthContext, useAuth, useUser, useConfig, useFeatures } from './AuthContext'
export { useAllauthAPI } from './APIContext'
export { ConfigContext, useAllauthConfig } from './ConfigContext'
export { StylesContext, useStyles, cx } from './StylesContext'
export { RouterContext, useRouter } from './RouterContext'