Flatten to three packages + extract mizan-runtime

packages/
  mizan-runtime/   Framework-agnostic state engine (~150 lines)
                   Context registry, batched invalidation, fetch primitives
  mizan-django/    Django server adapter (was packages/mizan-rpc/adapters/django/)
                   Codegen moved to mizan-django/generate/
  mizan-react/     React adapter (was packages/mizan-csr/adapters/react/)

Removed premature abstractions: mizan-ast, mizan-schema, mizan-rpc,
mizan-csr, mizan-ssr stub packages. The actual architecture is three
concrete packages, not five abstract layers.

mizan-runtime implements the v1 spec: registerContext with params,
scoped invalidation via microtask batching, server-driven invalidation
from mutation responses, mizanFetch for context bundles, mizanCall for
mutations.

264 Django + 33 React tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 15:47:17 -04:00
parent b28ee72c67
commit 787f90fd12
141 changed files with 167 additions and 15 deletions

View File

@@ -0,0 +1,142 @@
'use client'
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
type ReactNode,
} from 'react'
import { createDjangoCSRClient, Auth } from './index'
import type { BaseUser, AuthDetails, AuthRoutes } from './types'
/**
* Auth state provided by AuthContext.
*/
export interface AuthState<TUser extends BaseUser = BaseUser> {
/** Current user (null if not authenticated) */
user: TUser | null
/** Whether auth state is loading */
isLoading: boolean
/** Refresh user from server */
refresh: () => Promise<TUser | null>
}
const Context = createContext<AuthState | null>(null)
/**
* Default routes configuration.
*/
export const defaultRoutes: AuthRoutes = {
login: '/auth/login',
authenticated: '/dashboard',
}
const RoutesContext = createContext<AuthRoutes>(defaultRoutes)
export interface AuthContextProps<TUser extends BaseUser = BaseUser> {
children: ReactNode
/** Initial user from SSR hydration (null if not authenticated) */
user?: TUser | null
/** API endpoint to fetch user data (default: '/api/auth/me/') */
userEndpoint?: string
/** Route configuration for guards */
routes?: Partial<AuthRoutes>
}
/**
* Base auth context for Django-React apps.
*
* Provides user state from a simple /me endpoint.
* For allauth integration, use AllauthContext instead.
*/
// Create client once at module level (session auth, no dynamic config needed)
const client = createDjangoCSRClient(Auth.SESSION)
export function AuthContext<TUser extends BaseUser = BaseUser>({
children,
user: initialUser = null,
userEndpoint = '/api/auth/me/',
routes,
}: AuthContextProps<TUser>) {
const [user, setUser] = useState<TUser | null>(initialUser)
const [isLoading, setIsLoading] = useState(false)
const refresh = useCallback(async (): Promise<TUser | null> => {
setIsLoading(true)
try {
const resp = await client.request('GET', userEndpoint)
if (resp.ok) {
const userData = await resp.json()
setUser(userData)
return userData
} else if (resp.status === 401 || resp.status === 403) {
setUser(null)
return null
}
throw new Error(`Failed to fetch user: ${resp.status}`)
} catch (e) {
console.error('[AuthContext] Failed to fetch user:', e)
return null
} finally {
setIsLoading(false)
}
}, [userEndpoint])
const authState = useMemo<AuthState<TUser>>(() => ({
user,
isLoading,
refresh,
}), [user, isLoading, refresh])
const routesValue = useMemo(() => ({
...defaultRoutes,
...routes,
}), [routes])
return (
<RoutesContext value={routesValue}>
<Context value={authState}>
{children}
</Context>
</RoutesContext>
)
}
/**
* Hook to access auth state.
* Throws if used outside AuthContext.
*/
export function useAuthState<TUser extends BaseUser = BaseUser>(): AuthState<TUser> {
const ctx = useContext(Context)
if (!ctx) throw new Error('useAuthState must be used within AuthContext')
return ctx as AuthState<TUser>
}
/**
* Hook to access current user.
* Returns null if not authenticated.
*/
export function useUser<TUser extends BaseUser = BaseUser>(): TUser | null {
return useAuthState<TUser>().user
}
/**
* Hook to access auth details (isAuthenticated, isStaff, etc.)
*/
export function useAuth(): AuthDetails {
const user = useUser()
return {
isAuthenticated: user !== null,
isStaff: user?.is_staff ?? false,
isSuperuser: user?.is_superuser ?? false,
}
}
/**
* Hook to access route configuration.
*/
export function useAuthRoutes(): AuthRoutes {
return useContext(RoutesContext)
}

View File

@@ -0,0 +1,43 @@
'use client'
import { createContext, useContext, type ReactNode } from 'react'
/**
* Framework-agnostic router adapter.
* Implement this interface for your framework (Next.js, Remix, etc.)
*/
export interface RouterAdapter {
/** Navigate to a path (adds to history) */
push: (path: string) => void
/** Replace current path (no history entry) */
replace: (path: string) => void
/** Current pathname (e.g., "/account/login") */
pathname: string
/** Current search params */
searchParams: URLSearchParams
/** Get a specific route param (e.g., from /auth/[...path]) - optional */
getParam?: (name: string) => string | string[] | undefined
}
const Context = createContext<RouterAdapter | null>(null)
interface RouterContextProps {
children: ReactNode
router: RouterAdapter
}
/**
* Provides router adapter to route guards.
*/
export function RouterContext({ children, router }: RouterContextProps) {
return <Context value={router}>{children}</Context>
}
/**
* Hook to access router adapter.
*/
export function useRouter(): RouterAdapter {
const ctx = useContext(Context)
if (!ctx) throw new Error('useRouter must be used within RouterContext')
return ctx
}

View File

@@ -0,0 +1,585 @@
/**
* mizan/client
*
* HTTP client factories for Django backends.
* Framework-agnostic - works with vanilla JS, React, Vue, etc.
*
* ## Quick Start
*
* ### Client-Side (CSR)
* ```ts
* import { createDjangoCSRClient, Auth } from 'mizan/client'
*
* // Session-based (cookies + CSRF)
* const client = createDjangoCSRClient(Auth.SESSION)
*
* // JWT-based (Bearer token)
* const client = createDjangoCSRClient(Auth.JWT, { getAccessToken })
*
* const user = await client.json('GET', '/api/accounts/me/')
* ```
*
* ### Server-Side (SSR)
* ```ts
* import { createDjangoSSRClient } from 'mizan/client'
*
* const client = createDjangoSSRClient({
* cookies: await cookies() // Next.js cookies()
* })
*
* const user = await client.json('GET', '/api/accounts/me/')
* ```
*
* ## React Hooks
*
* For React, import from `/react`:
* ```tsx
* import { useDjangoCSRClient, Auth } from 'mizan/client/react'
*
* const client = useDjangoCSRClient(Auth.SESSION)
* ```
*/
// =============================================================================
// Types
// =============================================================================
/**
* Authentication strategy for client-side requests.
*/
export enum Auth {
/** Session cookies with CSRF token */
SESSION = 'session',
/** JWT Bearer token */
JWT = 'jwt',
}
/**
* Cookie getter interface (matches Next.js cookies() return type).
*/
export interface CookieGetter {
get(name: string): { name: string; value: string } | undefined
getAll(): { name: string; value: string }[]
}
/**
* Cookie configuration for SSR requests.
* Can be either a cookie getter (like Next.js cookies()) or pre-extracted values.
*/
export type SSRCookies = CookieGetter | {
/** CSRF token value */
csrf: string
/** Full cookie header string */
cookieHeader: string
}
/**
* The core HTTP client interface for Django requests.
*/
export interface DjangoHTTPClient {
/**
* Make an HTTP request, returning the raw Response.
*/
request(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<Response>
/**
* Make an HTTP request, parsing the response as JSON.
* @throws {HttpError} When response is not ok
*/
json<T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T>
}
/**
* Configuration for CSR client.
*/
export interface CSRClientConfig {
/** Base URL for the Django backend */
baseUrl?: string | (() => string)
}
/**
* Configuration for JWT-authenticated CSR client.
*/
export interface JWTClientConfig extends CSRClientConfig {
/** Async function that returns the current access token */
getAccessToken: () => Promise<string | null>
}
/**
* Configuration for SSR client.
*/
export interface SSRClientConfig {
/** Cookies for authentication forwarding */
cookies: SSRCookies
/** Internal backend URL override (defaults to http://${INTERNAL_BACKEND_HOSTNAME}:8000) */
baseUrl?: string
}
// =============================================================================
// Errors
// =============================================================================
/**
* Details about an HTTP error.
*/
export interface HttpErrorDetails {
status: number
statusText: string
url: string
bodyJson?: unknown
bodySnippet?: string
bodyIsHtml?: boolean
}
/**
* Error thrown when an HTTP request fails.
*/
export class HttpError extends Error {
constructor(
message: string,
public readonly details: HttpErrorDetails
) {
super(message)
this.name = 'HttpError'
}
}
// =============================================================================
// Internal Utilities
// =============================================================================
function getCookie(name: string): string | null {
if (typeof document === 'undefined') return null
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) {
return parts.pop()?.split(';').shift() ?? null
}
return null
}
function getCSRFToken(): string | null {
return getCookie('csrftoken')
}
interface RequestBuild {
request: RequestInit
hasBody: boolean
}
function buildRequest(method: string, data?: unknown, headers?: Record<string, string>): RequestBuild {
const isBodyMethod = !['GET', 'HEAD'].includes(method.toUpperCase())
const hasBody = isBodyMethod && data !== undefined
const requestHeaders: Record<string, string> = {
'Accept': 'application/json',
...headers,
}
let body: BodyInit | undefined
if (hasBody) {
if (data instanceof FormData) {
body = data
} else {
body = JSON.stringify(data)
requestHeaders['Content-Type'] = 'application/json'
}
}
return {
request: {
method: method.toUpperCase(),
headers: requestHeaders,
body,
credentials: 'include',
},
hasBody,
}
}
async function buildHttpError(resp: Response, url: URL | string): Promise<HttpError> {
const urlStr = typeof url === 'string' ? url : url.toString()
const details: HttpErrorDetails = {
status: resp.status,
statusText: resp.statusText,
url: urlStr,
}
try {
const contentType = resp.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
details.bodyJson = await resp.clone().json()
} else {
const text = await resp.clone().text()
details.bodyIsHtml = contentType.includes('text/html')
details.bodySnippet = text.slice(0, 500)
}
} catch {
// Ignore body parsing errors
}
return new HttpError(`Request failed: ${resp.status} ${resp.statusText}`, details)
}
// =============================================================================
// CSR Client Factory
// =============================================================================
/**
* Create a client-side HTTP client for Django.
*
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
* @param config - Client configuration
* @returns DjangoHTTPClient
*
* @example
* // Session-based
* const client = createDjangoCSRClient(Auth.SESSION)
*
* @example
* // JWT-based
* const client = createDjangoCSRClient(Auth.JWT, {
* getAccessToken: async () => localStorage.getItem('token')
* })
*/
export function createDjangoCSRClient(auth: Auth.SESSION, config?: CSRClientConfig): DjangoHTTPClient
export function createDjangoCSRClient(auth: Auth.JWT, config: JWTClientConfig): DjangoHTTPClient
export function createDjangoCSRClient(
auth: Auth,
config?: CSRClientConfig | JWTClientConfig
): DjangoHTTPClient {
if (!config?.baseUrl) {
throw new Error(
'baseUrl is required. Pass it via config or use MizanProvider which provides it automatically.'
)
}
const getBaseUrl = () => typeof config.baseUrl === 'function' ? config.baseUrl() : config.baseUrl!
const getHeaders = async (): Promise<Record<string, string>> => {
if (auth === Auth.JWT) {
const jwtConfig = config as JWTClientConfig
const token = await jwtConfig.getAccessToken()
if (token) {
return { Authorization: `Bearer ${token}` }
}
return {}
}
// Session auth uses CSRF
return { 'X-CSRFToken': getCSRFToken() ?? '' }
}
function resolveUrl(path: string): string {
const base = getBaseUrl()
// Absolute base URL — use URL constructor
if (base.startsWith('http://') || base.startsWith('https://')) {
return new URL(path, base).toString()
}
// Relative base — path is already usable by fetch in a browser
return path
}
return {
request: async (method, path, data?, headers?) => {
const url = resolveUrl(path)
const configHeaders = await getHeaders()
const build = buildRequest(method, data, { ...configHeaders, ...headers })
return fetch(url, build.request)
},
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
const url = resolveUrl(path)
const configHeaders = await getHeaders()
const build = buildRequest(method, data, { ...configHeaders, ...headers })
const resp = await fetch(url, build.request)
if (!resp.ok) {
throw await buildHttpError(resp, url)
}
return resp.json()
},
}
}
// =============================================================================
// Internal Backend URL Resolution
// =============================================================================
function getInternalBackendUrl(override?: string): string {
if (override) return override
throw new Error(
'baseUrl is required for SSR client. Pass it via config.'
)
}
// =============================================================================
// SSR Client Factory
// =============================================================================
/**
* Check if cookies is a CookieGetter interface.
*/
function isCookieGetter(cookies: SSRCookies): cookies is CookieGetter {
return typeof (cookies as CookieGetter).get === 'function'
}
/**
* Extract CSRF token and cookie header from SSRCookies.
*/
function extractCookies(cookies: SSRCookies): { csrf: string; cookieHeader: string } {
if (isCookieGetter(cookies)) {
return {
csrf: cookies.get('csrftoken')?.value ?? '',
cookieHeader: cookies.getAll().map(c => `${c.name}=${c.value}`).join('; ')
}
}
return cookies
}
/**
* Create a server-side HTTP client for Django.
* Used in SSR contexts (Next.js server components, server actions, etc.)
*
* @param config - SSR client configuration with cookies
* @returns DjangoHTTPClient
*
* @example
* // Next.js server component
* import { cookies } from 'next/headers'
*
* const client = createDjangoSSRClient({ cookies: await cookies() })
*/
// Re-export auth types for non-React usage
export type {
BaseUser,
AuthDetails,
AuthRoutes,
JWTTokens,
JWTConfig,
JWTState,
} from './types'
// Re-export RouterAdapter for libraries that extend it
export type { RouterAdapter } from './RouterContext'
export function createDjangoSSRClient(config: SSRClientConfig): DjangoHTTPClient {
const baseUrl = getInternalBackendUrl(config.baseUrl)
const { csrf, cookieHeader } = extractCookies(config.cookies)
return {
request: async (method, path, data?, headers?) => {
const url = new URL(path, baseUrl)
const requestHeaders: Record<string, string> = {
'Accept': 'application/json',
'X-CSRFToken': csrf,
'Cookie': cookieHeader,
...headers,
}
let body: BodyInit | undefined
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
if (data instanceof FormData) {
body = data
} else {
body = JSON.stringify(data)
requestHeaders['Content-Type'] = 'application/json'
}
}
return fetch(url, {
method: method.toUpperCase(),
headers: requestHeaders,
body,
credentials: 'include',
cache: 'no-store',
})
},
json: async <T>(method: string, path: string, data?: unknown, headers?: Record<string, string>): Promise<T> => {
const url = new URL(path, baseUrl)
const requestHeaders: Record<string, string> = {
'Accept': 'application/json',
'X-CSRFToken': csrf,
'Cookie': cookieHeader,
...headers,
}
let body: BodyInit | undefined
if (data && !['GET', 'HEAD'].includes(method.toUpperCase())) {
if (data instanceof FormData) {
body = data
} else {
body = JSON.stringify(data)
requestHeaders['Content-Type'] = 'application/json'
}
}
const resp = await fetch(url, {
method: method.toUpperCase(),
headers: requestHeaders,
body,
credentials: 'include',
cache: 'no-store',
})
if (!resp.ok) {
throw await buildHttpError(resp, url)
}
const contentType = resp.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
throw new Error(`Expected JSON response but got ${contentType}`)
}
return resp.json()
},
}
}
// =============================================================================
// SSR Session Initialization
// =============================================================================
/**
* Response from the session initialization endpoint.
*/
interface SessionInitResponse {
csrfToken: string
}
/**
* Ensure a Django session exists before making SSR requests.
*
* On first visit, the user has no cookies. This function pings Django to
* establish a session and get a CSRF token, which can then be used for
* subsequent hydration requests in the same SSR request chain.
*
* Note: Browser cookie forwarding is handled by Next.js middleware, not this
* function. This function only ensures cookies exist for SSR data fetching.
*
* @param config - SSR client configuration with cookies
* @returns Object with csrf token and cookie header for use in SSR requests
*
* @example
* // In layout.tsx
* const cookieStore = await cookies()
* const session = await ensureDjangoSession({ cookies: cookieStore })
* const client = createDjangoSSRClient({
* cookies: { csrf: session.csrf, cookieHeader: session.cookieHeader }
* })
*/
export async function ensureDjangoSession(config: SSRClientConfig): Promise<{
csrf: string
cookieHeader: string
}> {
const baseUrl = getInternalBackendUrl(config.baseUrl)
const { csrf: existingCsrf, cookieHeader: existingCookies } = extractCookies(config.cookies)
// If we already have a CSRF token, just return existing cookies
if (existingCsrf) {
return { csrf: existingCsrf, cookieHeader: existingCookies }
}
// No CSRF token - need to initialize session
const url = new URL('/api/mizan/session/', baseUrl)
const resp = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Cookie': existingCookies,
},
credentials: 'include',
cache: 'no-store',
})
if (!resp.ok) {
console.error('[mizan] Failed to initialize session:', resp.status, resp.statusText)
return { csrf: '', cookieHeader: existingCookies }
}
// Extract CSRF token from response body
const data: SessionInitResponse = await resp.json()
// Extract Set-Cookie headers to build updated cookie string for SSR chain
const setCookieHeaders = resp.headers.getSetCookie?.() ?? []
const newCookies = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
const combinedCookies = existingCookies
? `${existingCookies}; ${newCookies}`
: newCookies
return {
csrf: data.csrfToken,
cookieHeader: combinedCookies,
}
}
// =============================================================================
// Server Function HTTP Call
// =============================================================================
// Re-export error types from the canonical location
export type { FunctionErrorResponse } from '../errors'
import { DjangoError, type FunctionErrorResponse } from '../errors'
/**
* Success response from a server function
*/
export interface FunctionSuccessResponse<T> {
error: false
data: T
}
/**
* Union type for server function responses
*/
export type FunctionResponse<T> = FunctionSuccessResponse<T> | FunctionErrorResponse
// Cached CSR client for server function calls
let _csrClient: DjangoHTTPClient | null = null
function getCSRClient(): DjangoHTTPClient {
if (!_csrClient) {
_csrClient = createDjangoCSRClient(Auth.SESSION)
}
return _csrClient
}
/**
* Call a Django server function via HTTP.
* Used as fallback when WebSocket is unavailable.
*
* Uses the standard CSR client with session-based auth.
*
* @param baseUrl - Base URL for the API (e.g., '/api/mizan')
* @param functionName - Name of the server function
* @param input - Input data for the function
* @returns Promise resolving to the function output
* @throws FunctionErrorResponse on failure
*/
export async function httpFunctionCall<TInput = unknown, TOutput = unknown>(
baseUrl: string,
functionName: string,
input?: TInput
): Promise<TOutput> {
const client = getCSRClient()
// Use request() not json() because server functions return { error: true/false }
// in the body, not HTTP status codes for business errors
const response = await client.request(
'POST',
`${baseUrl}/call/`,
{ fn: functionName, args: input }
)
const data: FunctionResponse<TOutput> = await response.json()
if (data.error) {
throw new DjangoError(data)
}
return data.data
}

View File

@@ -0,0 +1,72 @@
'use client'
/**
* Next.js adapter for mizan/jwt.
*
* Usage:
* ```tsx
* // In layout.tsx
* import { NextAuthContext } from 'mizan/jwt/nextjs'
*
* export default function RootLayout({ children }) {
* return (
* <NextAuthContext user={user}>
* {children}
* </NextAuthContext>
* )
* }
* ```
*/
import { type ReactNode } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import type { RouterAdapter } from './RouterContext'
import { RouterContext } from './RouterContext'
import { AuthContext, type AuthContextProps } from './AuthContext'
import type { BaseUser, AuthRoutes } from './types'
/**
* Create a RouterAdapter from Next.js App Router hooks.
*/
export function useNextRouter(): RouterAdapter {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
return {
push: (path: string) => router.push(path),
replace: (path: string) => router.replace(path),
pathname,
searchParams: new URLSearchParams(searchParams.toString()),
}
}
export interface NextAuthContextProps<TUser extends BaseUser = BaseUser> {
children: ReactNode
/** Initial user from SSR hydration */
user?: TUser | null
/** API endpoint to fetch user data (default: '/api/auth/me/') */
userEndpoint?: string
/** Route configuration for guards */
routes?: Partial<AuthRoutes>
}
/**
* Next.js-specific AuthContext that handles the router automatically.
*/
export function NextAuthContext<TUser extends BaseUser = BaseUser>({
children,
user,
userEndpoint,
routes,
}: NextAuthContextProps<TUser>) {
const router = useNextRouter()
return (
<RouterContext router={router}>
<AuthContext user={user} userEndpoint={userEndpoint} routes={routes}>
{children}
</AuthContext>
</RouterContext>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import { useMemo } from 'react'
import { useJWT } from '../jwt/JWTContext'
import {
createDjangoCSRClient,
Auth,
type DjangoHTTPClient,
type CSRClientConfig,
} from './index'
// Re-export everything from main entry for convenience
export * from './index'
// Re-export auth components for React users
export * from './AuthContext'
export * from '../jwt/JWTContext'
export * from './RouterContext'
export * from './routing'
export type * from './types'
/**
* React hook that returns a client-side Django HTTP client.
*
* For SESSION auth, creates a session-based client with CSRF handling.
* For JWT auth, automatically wires up the JWTContext from mizan/jwt.
*
* @param auth - Authentication strategy (Auth.SESSION or Auth.JWT)
* @param config - Optional client configuration
* @returns DjangoHTTPClient
*
* @example
* // Session-based
* const client = useDjangoCSRClient(Auth.SESSION)
* const user = await client.json('GET', '/api/accounts/me/')
*
* @example
* // JWT-based (requires JWTContext from mizan/jwt)
* const client = useDjangoCSRClient(Auth.JWT)
* const user = await client.json('GET', '/api/accounts/me/')
*/
export function useDjangoCSRClient(auth: Auth, config?: CSRClientConfig): DjangoHTTPClient {
// Always call useJWT (React hooks must be unconditional)
// Returns null when outside JWTContext
const jwtContext = useJWT()
return useMemo(() => {
if (auth === Auth.JWT) {
if (!jwtContext?.getAccessToken) {
throw new Error(
'useDjangoCSRClient(Auth.JWT) requires JWTContext from mizan/jwt. ' +
'Wrap your component in JWTContext to use JWT authentication.'
)
}
return createDjangoCSRClient(Auth.JWT, {
...config,
getAccessToken: jwtContext.getAccessToken,
})
}
return createDjangoCSRClient(Auth.SESSION, config)
}, [auth, config, jwtContext?.getAccessToken])
}

View File

@@ -0,0 +1,74 @@
'use client'
import { useEffect, type ReactNode } from 'react'
import { useRouter } from './RouterContext'
import { useAuth, useAuthRoutes } from './AuthContext'
/**
* Route guard that only renders children if the user is authenticated.
* Redirects to login page if not authenticated.
*/
export function UserRoute({ children }: { children: ReactNode }) {
const router = useRouter()
const routes = useAuthRoutes()
const { isAuthenticated } = useAuth()
useEffect(() => {
if (!isAuthenticated) {
const searchParams = router.searchParams.toString()
const currentPath = searchParams
? `${router.pathname}?${searchParams}`
: router.pathname
const next = encodeURIComponent(currentPath)
router.replace(`${routes.login}?next=${next}`)
}
}, [isAuthenticated, router, routes.login])
if (!isAuthenticated) return null
return children
}
/**
* Route guard that only renders children if the user is authenticated AND is staff.
* Redirects to login if not authenticated, or to authenticated route if not staff.
*/
export function StaffRoute({ children }: { children: ReactNode }) {
const router = useRouter()
const routes = useAuthRoutes()
const { isAuthenticated, isStaff } = useAuth()
useEffect(() => {
if (!isAuthenticated) {
const searchParams = router.searchParams.toString()
const currentPath = searchParams
? `${router.pathname}?${searchParams}`
: router.pathname
const next = encodeURIComponent(currentPath)
router.replace(`${routes.login}?next=${next}`)
} else if (!isStaff) {
router.replace(routes.authenticated)
}
}, [isAuthenticated, isStaff, router, routes])
if (!isAuthenticated || !isStaff) return null
return children
}
/**
* Route guard that only renders children if the user is NOT authenticated.
* Redirects to authenticated route if already logged in.
*/
export function AnonymousRoute({ children }: { children: ReactNode }) {
const router = useRouter()
const routes = useAuthRoutes()
const { isAuthenticated } = useAuth()
useEffect(() => {
if (isAuthenticated) {
router.replace(routes.authenticated)
}
}, [isAuthenticated, routes.authenticated, router])
if (isAuthenticated) return null
return children
}

View File

@@ -0,0 +1,66 @@
/**
* Base user type - extend this for your app's user model.
*/
export interface BaseUser {
id?: number | string
email?: string
username?: string
is_staff?: boolean
is_superuser?: boolean
[key: string]: unknown
}
/**
* Auth state derived from user.
*/
export interface AuthDetails {
isAuthenticated: boolean
isStaff: boolean
isSuperuser: boolean
}
/**
* Configuration for route guards.
*/
export interface AuthRoutes {
login: string
authenticated: string
}
/**
* JWT token structure.
*/
export interface JWTTokens {
accessToken: string
refreshToken: string
expiresAt: number // Unix timestamp in ms
}
/**
* JWT endpoint configuration.
*/
export interface JWTConfig {
/** Base URL for API calls (default: '' - use relative URLs) */
baseUrl?: string
/** mizan server function endpoint (default: /api/mizan/call/) */
endpoint?: string
/** Seconds before expiry to trigger refresh (default: 30) */
refreshBuffer?: number
/** Auto-obtain tokens on mount (default: true) */
autoObtain?: boolean
/** Auto-refresh tokens before expiry (default: true) */
autoRefresh?: boolean
}
/**
* JWT state and methods.
*/
export interface JWTState {
tokens: JWTTokens | null
isLoading: boolean
error: Error | null
obtainTokens: () => Promise<JWTTokens | null>
refreshTokens: () => Promise<JWTTokens | null>
clearTokens: () => void
getAccessToken: () => Promise<string | null>
}