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:
72
legacy/allauth/contexts/APIContext.tsx
Normal file
72
legacy/allauth/contexts/APIContext.tsx
Normal 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])
|
||||
}
|
||||
116
legacy/allauth/contexts/AllauthContext.tsx
Normal file
116
legacy/allauth/contexts/AllauthContext.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
legacy/allauth/contexts/AuthContext.tsx
Normal file
153
legacy/allauth/contexts/AuthContext.tsx
Normal 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 ?? [],
|
||||
}
|
||||
}
|
||||
29
legacy/allauth/contexts/ConfigContext.tsx
Normal file
29
legacy/allauth/contexts/ConfigContext.tsx
Normal 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)
|
||||
}
|
||||
31
legacy/allauth/contexts/RouterContext.tsx
Normal file
31
legacy/allauth/contexts/RouterContext.tsx
Normal 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
|
||||
}
|
||||
49
legacy/allauth/contexts/StylesContext.tsx
Normal file
49
legacy/allauth/contexts/StylesContext.tsx
Normal 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(' ')
|
||||
}
|
||||
6
legacy/allauth/contexts/index.ts
Normal file
6
legacy/allauth/contexts/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user