Full test infrastructure, code audit fixes, and real E2E integration tests

Test infrastructure:
- Django standalone test runner (pytest-django, test settings, EmailUser model)
- React unit tests via Vitest with jsdom, jest compat layer, path aliases
- Playwright E2E tests using generated hooks in a real Chromium browser
- Docker Compose test backend (Django + Redis) for integration testing
- Desktop integration test app (PyWebView + Django + uvicorn)
- Makefile with test/test-django/test-react/test-integration targets

Library bugs found and fixed:
- hasJWT truthiness: undefined !== null was true, skipping session init
- process.env crash: CSR client referenced process.env in non-Node browsers
- baseUrl not forwarded: DjareaProvider didn't pass baseUrl to CSR client
- Relative URL handling: new URL() failed with relative base paths
- call() race condition: HTTP requests fired before CSRF cookie was set
- Session init await: added sessionRef promise so call() waits for session
- path_prefix on schema export: both export commands failed with URL reverse
- NullBooleanField removed: referenced field doesn't exist in Django 5.0+
- lru_cache on JWT settings: get_settings() now cached as intended
- Channel message routing: broadcasts now include channel name and params
- httpFunctionCall: fixed URL and request body format

Generator fixes:
- Removed 1,100 lines of REST/OpenAPI client generation (not part of Djarea)
- Generator now works for djarea-only projects without django-ninja REST APIs
- Generated DjangoContext now includes ChannelProvider when channels exist
- Fixed env var passthrough for schema export commands
- Deduplicated fetch logic into single runDjangoCommand helper

Test quality:
- Fixed 33 tautological Django tests with real assertions
- Found hidden bug: benchmark functions were never registered
- Found hidden bug: unicode lookalike test used plain ASCII
- Deleted worthless React unit tests (duplicates, shape checks, Zod-tests-Zod)
- Replaced jsdom integration tests with Playwright browser tests

Example apps:
- example/: Integration test backend with 33 server functions, 5 forms,
  4 channels covering auth variations, contexts, class-based ServerFunction,
  error codes, DjareaFormMixin, formsets, and JWT
- desktop/: PyWebView desktop app with file system access, SQLite CRUD,
  system introspection, and 39 real HTTP integration tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 01:17:48 -04:00
commit 4451ec24a1
179 changed files with 27699 additions and 0 deletions

2
react/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

26
react/README.md Normal file
View File

@@ -0,0 +1,26 @@
# djarea (TypeScript)
TypeScript client library for the Djarea framework. See the [monorepo root](../README.md) for full documentation.
## Installation
```bash
# From git
npm install djarea@git+https://git.impactsoundworks.com/isw/djarea.git#workspace=react
# Local development
npm install djarea@file:../../web/djarea/react
```
## Exports
| Import | Purpose |
|--------|---------|
| `djarea` | Core: DjareaProvider, hooks, forms, errors |
| `djarea/client` | HTTP clients, SSR helpers, `ensureDjangoSession()` |
| `djarea/client/react` | React-specific client hooks |
| `djarea/client/nextjs` | Next.js integration |
| `djarea/channels` | WebSocket channels |
| `djarea/jwt` | JWT token management |
| `djarea/allauth` | Allauth UI components |
| `djarea/allauth/nextjs` | Next.js allauth context |

85
react/package.json Normal file
View File

@@ -0,0 +1,85 @@
{
"name": "@rythazhur/djarea",
"version": "0.1.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js"
},
"./client/react": {
"types": "./dist/client/react.d.ts",
"import": "./dist/client/react.js"
},
"./client/nextjs": {
"types": "./dist/client/nextjs.d.ts",
"import": "./dist/client/nextjs.js"
},
"./channels": {
"types": "./dist/channels/index.d.ts",
"import": "./dist/channels/index.js"
},
"./jwt": {
"types": "./dist/jwt/index.d.ts",
"import": "./dist/jwt/index.js"
},
"./allauth": {
"types": "./dist/allauth/index.d.ts",
"import": "./dist/allauth/index.js"
},
"./allauth/nextjs": {
"types": "./dist/allauth/nextjs.d.ts",
"import": "./dist/allauth/nextjs.js"
}
},
"bin": {
"djarea-generate": "./dist/generator/cli.mjs"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"require('fs').cpSync('src/generator','dist/generator',{recursive:true})\"",
"dev": "tsc -p tsconfig.build.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "RUN_INTEGRATION_TESTS=true NEXT_PUBLIC_HOST_URL=http://localhost:8000 vitest run",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"next": {
"optional": true
}
},
"dependencies": {
"ws": "^8.19.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@simplewebauthn/browser": "^13.2.2",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ws": "^8.5.0",
"jsdom": "^25.0.0",
"next": "^16.1.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0",
"zod": "^4.3.6"
},
"optionalDependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.0.0",
"openapi-typescript": "^7.0.0"
}
}

View File

@@ -0,0 +1,314 @@
/**
* Tests for Django Server React Context
*
* Unit tests run without backend.
* Integration tests require: docker-compose up
*
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
*/
import React from 'react'
import { render, screen, waitFor, act } from '@testing-library/react'
import {
DjareaProvider,
useDjarea,
useDjareaStatus,
useDjareaCall,
// Legacy aliases for backwards compatibility tests
DjangoContext,
useDjango,
useDjangoStatus,
useServerFunction,
} from '../context'
import { DjangoError } from '../errors'
import { describeIntegration, BACKEND_URL } from '../testing'
// ============================================================================
// Unit Tests (no backend required)
// ============================================================================
describe('Djarea Context (unit)', () => {
describe('useDjarea hook', () => {
it('should throw when used outside provider', () => {
function TestComponent() {
useDjarea()
return <div>Test</div>
}
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
expect(() => render(<TestComponent />)).toThrow(
'useDjarea must be used within a DjareaProvider'
)
consoleSpy.mockRestore()
})
it('should return context value inside provider', () => {
let contextValue: any = null
function TestComponent() {
contextValue = useDjarea()
return <div>Test</div>
}
render(
<DjareaProvider autoConnect={false}>
<TestComponent />
</DjareaProvider>
)
expect(contextValue).not.toBeNull()
expect(contextValue!.status).toBe('disconnected')
})
})
describe('useDjareaStatus hook', () => {
it('should return disconnected when autoConnect is false', () => {
function TestComponent() {
const status = useDjareaStatus()
return <div data-testid="status">{status}</div>
}
render(
<DjareaProvider autoConnect={false}>
<TestComponent />
</DjareaProvider>
)
expect(screen.getByTestId('status')).toHaveTextContent('disconnected')
})
})
describe('hydration', () => {
it('should initialize context store from hydration data', () => {
let contextValue: any = null
function TestComponent() {
contextValue = useDjarea()
return <div>Test</div>
}
const hydration = {
auth_status: { is_authenticated: false },
user: null,
}
render(
<DjareaProvider hydration={hydration} autoConnect={false}>
<TestComponent />
</DjareaProvider>
)
expect(contextValue.getContext('auth_status')).toEqual({ is_authenticated: false })
expect(contextValue.getContext('user')).toEqual(null)
})
})
})
// ============================================================================
// Integration Tests (require running backend)
// ============================================================================
describeIntegration('Djarea Context (integration)', () => {
describe('server function calls via HTTP', () => {
it('should call echo function and get response', async () => {
let result: any = null
let error: any = null
function TestComponent() {
const { call, status } = useDjango()
React.useEffect(() => {
// Use HTTP fallback (status will be disconnected without WebSocket)
call<{ text: string }, { message: string }>('echo', { text: 'context test' })
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div data-testid="status">{status}</div>
}
render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
<TestComponent />
</DjangoContext>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
expect(error).toBeNull()
expect(result).toHaveProperty('message')
expect(result.message).toContain('context test')
})
it('should call add function with correct result', async () => {
let result: any = null
let error: any = null
function TestComponent() {
const { call } = useDjango()
React.useEffect(() => {
call<{ a: number; b: number }, { result: number }>('add', { a: 10, b: 20 })
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div>Test</div>
}
render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
<TestComponent />
</DjangoContext>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
expect(error).toBeNull()
expect(result).toEqual({ result: 30 })
})
it('should throw DjangoError for validation errors', async () => {
let result: any = null
let error: any = null
function TestComponent() {
const { call } = useDjango()
React.useEffect(() => {
// Call without required field
call('echo', {})
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div>Test</div>
}
render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
<TestComponent />
</DjangoContext>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
expect(result).toBeNull()
expect(error).toBeInstanceOf(DjangoError)
})
})
describe('useServerFunction hook', () => {
it('should create typed function that calls backend', async () => {
let result: any = null
let error: any = null
interface EchoInput { text: string }
interface EchoOutput { message: string }
function TestComponent() {
const echo = useServerFunction<EchoInput, EchoOutput>('echo')
React.useEffect(() => {
echo({ text: 'typed function test' })
.then((r) => { result = r })
.catch((e) => { error = e })
}, [echo])
return <div>Test</div>
}
render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
<TestComponent />
</DjangoContext>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
expect(error).toBeNull()
expect(result).toHaveProperty('message')
expect(result.message).toContain('typed function test')
})
})
describe('form functions', () => {
it('should call login.schema and get form fields', async () => {
let result: any = null
let error: any = null
function TestComponent() {
const { call } = useDjango()
React.useEffect(() => {
call('login.schema', { data: {} })
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div>Test</div>
}
render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
<TestComponent />
</DjangoContext>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
expect(error).toBeNull()
expect(result).toHaveProperty('fields')
expect(result).toHaveProperty('meta')
// Login form should have login and password fields
expect(result.fields).toHaveProperty('login')
expect(result.fields).toHaveProperty('password')
})
it('should call login.validate and get validation result', async () => {
let result: any = null
let error: any = null
function TestComponent() {
const { call } = useDjango()
React.useEffect(() => {
call('login.validate', {
data: { login: 'test@example.com', password: 'testpass' }
})
.then((r) => { result = r })
.catch((e) => { error = e })
}, [call])
return <div>Test</div>
}
render(
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
<TestComponent />
</DjangoContext>
)
await waitFor(() => {
expect(result || error).not.toBeNull()
}, { timeout: 5000 })
// Should return validation result (may have errors for invalid creds, that's ok)
expect(error).toBeNull()
expect(result).toHaveProperty('valid')
})
})
})

View File

@@ -0,0 +1,214 @@
/**
* Tests for Django Server Error
*/
import { DjangoError, type FunctionErrorResponse } from '../errors'
describe('DjangoError', () => {
it('should create error with message and code', () => {
const response: FunctionErrorResponse = {
error: true,
code: 'NOT_FOUND',
message: 'Function not found',
}
const error = new DjangoError(response)
expect(error.message).toBe('Function not found')
expect(error.code).toBe('NOT_FOUND')
expect(error.name).toBe('DjangoError')
})
it('should preserve details', () => {
const response: FunctionErrorResponse = {
error: true,
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: {
fields: {
name: ['Required', 'Too short'],
email: ['Invalid format'],
},
},
}
const error = new DjangoError(response)
expect(error.details).toBeDefined()
expect(error.details?.fields?.name).toEqual(['Required', 'Too short'])
})
it('should preserve original response', () => {
const response: FunctionErrorResponse = {
error: true,
code: 'INTERNAL_ERROR',
message: 'Server error',
}
const error = new DjangoError(response)
expect(error.response).toBe(response)
})
describe('isValidationError', () => {
it('should return true for validation errors', () => {
const error = new DjangoError({
error: true,
code: 'VALIDATION_ERROR',
message: 'Invalid',
})
expect(error.isValidationError()).toBe(true)
})
it('should return false for other errors', () => {
const error = new DjangoError({
error: true,
code: 'NOT_FOUND',
message: 'Not found',
})
expect(error.isValidationError()).toBe(false)
})
})
describe('isAuthError', () => {
it('should return true for unauthorized', () => {
const error = new DjangoError({
error: true,
code: 'UNAUTHORIZED',
message: 'Not authenticated',
})
expect(error.isAuthError()).toBe(true)
})
it('should return true for forbidden', () => {
const error = new DjangoError({
error: true,
code: 'FORBIDDEN',
message: 'Access denied',
})
expect(error.isAuthError()).toBe(true)
})
it('should return false for other errors', () => {
const error = new DjangoError({
error: true,
code: 'NOT_FOUND',
message: 'Not found',
})
expect(error.isAuthError()).toBe(false)
})
})
describe('isNotFound', () => {
it('should return true for not found errors', () => {
const error = new DjangoError({
error: true,
code: 'NOT_FOUND',
message: 'Not found',
})
expect(error.isNotFound()).toBe(true)
})
it('should return false for other errors', () => {
const error = new DjangoError({
error: true,
code: 'VALIDATION_ERROR',
message: 'Invalid',
})
expect(error.isNotFound()).toBe(false)
})
})
describe('getFieldErrors', () => {
it('should return field errors for validation error', () => {
const error = new DjangoError({
error: true,
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: {
fields: {
name: ['Required'],
email: ['Invalid'],
},
},
})
const errors = error.getFieldErrors()
expect(errors).toEqual({
name: ['Required'],
email: ['Invalid'],
})
})
it('should return null for non-validation errors', () => {
const error = new DjangoError({
error: true,
code: 'NOT_FOUND',
message: 'Not found',
})
expect(error.getFieldErrors()).toBeNull()
})
it('should return null if no fields in details', () => {
const error = new DjangoError({
error: true,
code: 'VALIDATION_ERROR',
message: 'Invalid',
details: {},
})
expect(error.getFieldErrors()).toBeNull()
})
})
describe('getFieldError', () => {
it('should return first error for a field', () => {
const error = new DjangoError({
error: true,
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: {
fields: {
name: ['Required', 'Too short'],
},
},
})
expect(error.getFieldError('name')).toBe('Required')
})
it('should return null for non-existent field', () => {
const error = new DjangoError({
error: true,
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: {
fields: {
name: ['Required'],
},
},
})
expect(error.getFieldError('email')).toBeNull()
})
it('should return null for non-validation errors', () => {
const error = new DjangoError({
error: true,
code: 'NOT_FOUND',
message: 'Not found',
})
expect(error.getFieldError('name')).toBeNull()
})
})
})

View File

@@ -0,0 +1,362 @@
/**
* Tests for Django Forms
*
* Integration tests call the REAL backend - no mocks.
* Backend must be running: docker-compose up
*
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
*/
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { z } from 'zod'
import {
useDjangoFormCore,
type FormCoreConfig,
} from '../forms'
import { DjangoContext } from '../context'
import { describeIntegration, BACKEND_URL } from '../testing'
// ============================================================================
// Test Setup
// ============================================================================
// Helper to render hook with provider
function renderFormHook<TData extends Record<string, unknown>>(
config: FormCoreConfig<TData>
) {
return renderHook(() => useDjangoFormCore<TData>(config), {
wrapper: ({ children }) => (
<DjangoContext baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
{children}
</DjangoContext>
),
})
}
// ============================================================================
// Integration Tests - Real Backend Calls
// ============================================================================
describeIntegration('useDjangoFormCore (integration)', () => {
describe('Schema loading from real backend', () => {
it('loads login form schema', async () => {
const { result } = renderFormHook({
name: 'login',
})
expect(result.current.loading).toBe(true)
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
expect(result.current.schema).not.toBeNull()
expect(result.current.schema?.name).toBe('login')
// Login form should have login and password fields
expect(result.current.schema?.fields).toHaveProperty('login')
expect(result.current.schema?.fields).toHaveProperty('password')
})
it('loads signup form schema', async () => {
const { result } = renderFormHook({
name: 'signup',
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
expect(result.current.schema).not.toBeNull()
expect(result.current.schema?.name).toBe('signup')
// Signup form should have email and password fields
expect(result.current.schema?.fields).toHaveProperty('email')
expect(result.current.schema?.fields).toHaveProperty('password1')
})
it('loads add_email form schema', async () => {
const { result } = renderFormHook({
name: 'add_email',
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
expect(result.current.schema).not.toBeNull()
expect(result.current.schema?.fields).toHaveProperty('email')
})
})
describe('Form data management', () => {
it('sets and gets form data', async () => {
const { result } = renderFormHook({
name: 'login',
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
act(() => {
result.current.set('login', 'test@example.com')
result.current.set('password', 'testpassword123')
})
expect(result.current.data.login).toBe('test@example.com')
expect(result.current.data.password).toBe('testpassword123')
})
it('tracks touched fields', async () => {
const { result } = renderFormHook({
name: 'login',
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
expect(result.current.touchedFields.size).toBe(0)
act(() => {
result.current.touch('login')
})
expect(result.current.touchedFields.has('login')).toBe(true)
expect(result.current.touchedFields.has('password')).toBe(false)
})
it('resets form state', async () => {
const { result } = renderFormHook({
name: 'login',
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
act(() => {
result.current.set('login', 'changed@example.com')
result.current.touch('login')
})
expect(result.current.data.login).toBe('changed@example.com')
expect(result.current.touchedFields.has('login')).toBe(true)
act(() => {
result.current.reset()
})
expect(result.current.data.login).toBe('')
expect(result.current.touchedFields.size).toBe(0)
expect(result.current.errors).toBeNull()
})
})
describe('Zod validation with real schema', () => {
// Define Zod schema matching login form
const LoginZodSchema = z.object({
login: z.string().min(1, 'Login is required').email('Invalid email'),
password: z.string().min(1, 'Password is required'),
})
type LoginData = z.infer<typeof LoginZodSchema>
it('validates with Zod schema on touch', async () => {
const { result } = renderFormHook<LoginData>({
name: 'login',
zodSchema: LoginZodSchema,
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
// Set invalid value
act(() => {
result.current.set('login', 'not-an-email')
})
// Touch triggers validation
act(() => {
result.current.touch('login')
})
// Zod validation should show email format error
expect(result.current.errors?.fields.login).toBeDefined()
expect(result.current.errors?.fields.login?.[0]?.message).toBe('Invalid email')
expect(result.current.errors?.fields.login?.[0]?.source).toBe('zod')
})
it('clears errors when value becomes valid', async () => {
const { result } = renderFormHook<LoginData>({
name: 'login',
zodSchema: LoginZodSchema,
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
// Set invalid value and touch
act(() => {
result.current.set('login', 'bad')
})
act(() => {
result.current.touch('login')
})
expect(result.current.errors?.fields.login).toBeDefined()
// Set valid value and touch
act(() => {
result.current.set('login', 'valid@example.com')
})
act(() => {
result.current.touch('login')
})
expect(result.current.errors?.fields.login).toBeUndefined()
})
it('tracks hasErrors correctly', async () => {
const { result } = renderFormHook<LoginData>({
name: 'login',
zodSchema: LoginZodSchema,
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
expect(result.current.hasErrors).toBe(false)
// Set invalid and touch
act(() => {
result.current.set('login', 'bad')
})
act(() => {
result.current.touch('login')
})
expect(result.current.hasErrors).toBe(true)
// Set valid and touch
act(() => {
result.current.set('login', 'good@example.com')
})
act(() => {
result.current.touch('login')
})
expect(result.current.hasErrors).toBe(false)
})
})
describe('Form submission', () => {
it('submits login form and handles validation errors', async () => {
const { result } = renderFormHook({
name: 'login',
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
// Set invalid credentials
act(() => {
result.current.set('login', 'nonexistent@example.com')
result.current.set('password', 'wrongpassword')
})
// Submit should fail with validation error
let submitResult: any
await act(async () => {
submitResult = await result.current.submit()
})
// Submit should return error (invalid credentials)
// The exact error depends on backend behavior
expect(submitResult).toBeDefined()
})
it('submits signup form with missing required fields', async () => {
const { result } = renderFormHook({
name: 'signup',
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
// Submit with empty fields should return validation errors
let submitResult: any
await act(async () => {
submitResult = await result.current.submit()
})
// Should have validation errors for required fields
expect(submitResult).toBeDefined()
expect(submitResult.success).toBe(false)
})
})
describe('Error source tagging', () => {
const LoginZodSchema = z.object({
login: z.string().min(1, 'Login is required').email('Invalid email'),
password: z.string().min(1, 'Password is required'),
})
type LoginData = z.infer<typeof LoginZodSchema>
it('tags Zod errors with source: zod', async () => {
const { result } = renderFormHook<LoginData>({
name: 'login',
zodSchema: LoginZodSchema,
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
act(() => {
result.current.set('login', 'invalid')
})
act(() => {
result.current.touch('login')
})
const errors = result.current.getFieldErrors('login')
expect(errors.length).toBeGreaterThan(0)
expect(errors[0].source).toBe('zod')
})
it('filters errors by source', async () => {
const { result } = renderFormHook<LoginData>({
name: 'login',
zodSchema: LoginZodSchema,
})
await waitFor(() => {
expect(result.current.loading).toBe(false)
}, { timeout: 5000 })
act(() => {
result.current.set('login', 'invalid')
})
act(() => {
result.current.touch('login')
})
// Should have Zod errors
const zodErrors = result.current.getFieldErrors('login', { source: 'zod' })
expect(zodErrors.length).toBeGreaterThan(0)
// Should have no server errors yet
const serverErrors = result.current.getFieldErrors('login', { source: 'server' })
expect(serverErrors.length).toBe(0)
})
})
})

View File

@@ -0,0 +1,824 @@
/**
* Cross-cutting integration tests for djarea
*
* Tests error paths and protocol correctness across HTTP, Forms, and WebSocket.
* Requires a running backend: docker-compose up
*
* Run with: RUN_INTEGRATION_TESTS=true npm run test
*/
import { renderHook, act } from '@testing-library/react'
import { ReactNode } from 'react'
import { describeIntegration, BACKEND_URL, WS_URL } from '../testing'
import { DjareaProvider, useDjarea } from '../context'
import { DjangoError } from '../errors'
import { ChannelConnection } from '../channels/connection'
import { RPCError } from '../channels/connection'
function Wrapper({ children }: { children: ReactNode }) {
return (
<DjareaProvider baseUrl={`${BACKEND_URL}/api/djarea`} autoConnect={false}>
{children}
</DjareaProvider>
)
}
// Helper to get call function
function useCall() {
const { call } = useDjarea()
return call
}
// Helper to wait for a ChannelConnection to reach 'connected' status
function waitForConnected(connection: ChannelConnection): Promise<void> {
return new Promise((resolve) => {
if (connection.status === 'connected') { resolve(); return }
const unsub = connection.onStatusChange((status) => {
if (status === 'connected') { unsub(); resolve() }
})
})
}
// ============================================================================
// Group 1: Executor framework validation
// ============================================================================
describeIntegration('Executor framework validation', () => {
it('should return VALIDATION_ERROR with field details for wrong input types', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('add', { a: 'hello', b: 'world' })
} catch (e) {
error = e as DjangoError
}
})
expect(error).toBeInstanceOf(DjangoError)
expect(error!.code).toBe('VALIDATION_ERROR')
expect(error!.isValidationError()).toBe(true)
const fieldErrors = error!.getFieldErrors()
expect(fieldErrors).not.toBeNull()
expect(Object.keys(fieldErrors!).length).toBeGreaterThan(0)
})
it('should return NOT_FOUND for non-existent function', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('this_function_does_not_exist', {})
} catch (e) {
error = e as DjangoError
}
})
expect(error).toBeInstanceOf(DjangoError)
expect(error!.code).toBe('NOT_FOUND')
})
it('should return FORBIDDEN for auth-required function when anonymous', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('whoami', {})
} catch (e) {
error = e as DjangoError
}
})
expect(error).toBeInstanceOf(DjangoError)
expect(error!.isAuthError()).toBe(true)
})
it('should return VALIDATION_ERROR with specific field for missing required input', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('echo', {})
} catch (e) {
error = e as DjangoError
}
})
expect(error).toBeInstanceOf(DjangoError)
expect(error!.code).toBe('VALIDATION_ERROR')
const fieldErrors = error!.getFieldErrors()
expect(fieldErrors).not.toBeNull()
expect(fieldErrors!).toHaveProperty('text')
})
})
// ============================================================================
// Group 2: Form framework validation
// ============================================================================
describeIntegration('Form framework validation', () => {
it('should return field metadata with types and required flags', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('login.schema', { data: {} })
})
expect(response).toHaveProperty('fields')
// Each field should have name, label, type, required, widget
const fields = response.fields
for (const fieldKey of Object.keys(fields)) {
const field = fields[fieldKey]
expect(field).toHaveProperty('name')
expect(field).toHaveProperty('label')
expect(field).toHaveProperty('type')
expect(field).toHaveProperty('required')
expect(field).toHaveProperty('widget')
}
// login field should be required
expect(fields.login.required).toBe(true)
// password field widget should contain 'password'
expect(fields.password.widget.toLowerCase()).toContain('password')
})
it('should return field-level errors for empty form validation', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('login.validate', { data: {} })
})
expect(response.valid).toBe(false)
expect(response.errors).toBeInstanceOf(Array)
expect(response.errors.length).toBeGreaterThan(0)
})
it('should return form-level error for wrong login credentials', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('login.submit', {
login: 'wrong@example.com',
password: 'wrongpass',
})
})
expect(response.success).toBe(false)
expect(JSON.stringify(response.errors)).toContain('Invalid login credentials')
})
})
// ============================================================================
// Group 3: WebSocket framework validation
// ============================================================================
describeIntegration('WebSocket framework validation', () => {
let connection: ChannelConnection
beforeEach(async () => {
connection = new ChannelConnection({ url: WS_URL, reconnect: false })
connection.connect()
// Wait for connected status
await new Promise<void>((resolve) => {
const unsub = connection.onStatusChange((status) => {
if (status === 'connected') {
unsub()
resolve()
}
})
if (connection.status === 'connected') {
unsub()
resolve()
}
})
})
afterEach(() => {
connection.disconnect()
})
it('should deliver messages back through channel subscription', async () => {
// Subscribe to chat channel and wait for confirmation
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Subscribe timeout')), 5000)
const unsub = connection.onMessage((msg) => {
if ('subscribed' in msg && msg.channel === 'chat') {
clearTimeout(timeout)
unsub()
resolve()
}
})
connection.send({
action: 'subscribe',
channel: 'chat',
params: { room: 'integration-test' },
})
})
// Listen for the echoed message
const messagePromise = new Promise<any>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Message timeout')), 5000)
const unsub = connection.onMessage((msg) => {
if ('data' in msg) {
clearTimeout(timeout)
unsub()
resolve(msg)
}
})
})
// Send a message
connection.send({
action: 'message',
channel: 'chat',
params: { room: 'integration-test' },
data: { text: 'hello from integration test' },
})
const received = await messagePromise
expect(received.data).toHaveProperty('text')
expect(received.data.text).toBe('hello from integration test')
})
it('should return error for unknown channel subscription', async () => {
const errorPromise = new Promise<any>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Error response timeout')), 5000)
const unsub = connection.onMessage((msg) => {
if ('error' in msg) {
clearTimeout(timeout)
unsub()
resolve(msg)
}
})
})
connection.send({
action: 'subscribe',
channel: 'nonexistent_channel',
params: {},
})
const errorMsg = await errorPromise
expect(errorMsg.error.toLowerCase()).toContain('unknown channel')
})
it('should reject HTTP-only function via WebSocket RPC', async () => {
let rpcError: RPCError | null = null
try {
await connection.rpc('http_only_echo', { text: 'test' })
} catch (e) {
rpcError = e as RPCError
}
expect(rpcError).toBeInstanceOf(RPCError)
})
it('should return NOT_FOUND for non-existent RPC function', async () => {
let rpcError: RPCError | null = null
try {
await connection.rpc('does_not_exist', {})
} catch (e) {
rpcError = e as RPCError
}
expect(rpcError).toBeInstanceOf(RPCError)
expect(rpcError!.code).toBe('NOT_FOUND')
})
it('should return VALIDATION_ERROR for wrong RPC input types', async () => {
let rpcError: RPCError | null = null
try {
await connection.rpc('add', { a: 'not_number', b: 'also_not' })
} catch (e) {
rpcError = e as RPCError
}
expect(rpcError).toBeInstanceOf(RPCError)
expect(rpcError!.code).toBe('VALIDATION_ERROR')
})
})
// ============================================================================
// Group 4: HTTP happy path
// ============================================================================
describeIntegration('HTTP happy path', () => {
it('should call echo and receive echoed text', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('echo', { text: 'hello world' })
})
expect(response).toHaveProperty('message')
expect(response.message).toContain('hello world')
})
it('should call add and receive correct sum', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('add', { a: 17, b: 25 })
})
expect(response).toEqual({ result: 42 })
})
it('should call class-based ServerFunction (multiply)', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('multiply', { x: 7, y: 6 })
})
expect(response).toEqual({ product: 42 })
})
})
// ============================================================================
// Group 5: Auth variations
// ============================================================================
describeIntegration('Auth variations', () => {
it('should reject staff_only for anonymous with UNAUTHORIZED', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('staff_only', {})
} catch (e) {
error = e as DjangoError
}
})
expect(error).toBeInstanceOf(DjangoError)
expect(error!.code).toBe('UNAUTHORIZED')
expect(error!.isAuthError()).toBe(true)
})
it('should reject superuser_only for anonymous with UNAUTHORIZED', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('superuser_only', {})
} catch (e) {
error = e as DjangoError
}
})
expect(error).toBeInstanceOf(DjangoError)
expect(error!.code).toBe('UNAUTHORIZED')
expect(error!.isAuthError()).toBe(true)
})
it('should reject verified_only for anonymous (callable auth)', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('verified_only', {})
} catch (e) {
error = e as DjangoError
}
})
// Callable auth returns False for anonymous, which maps to FORBIDDEN
expect(error).toBeInstanceOf(DjangoError)
expect(error!.code).toBe('FORBIDDEN')
expect(error!.isAuthError()).toBe(true)
})
})
// ============================================================================
// Group 6: Context functions
// ============================================================================
describeIntegration('Context functions', () => {
it('should call global context current_user and get anonymous response', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('current_user', {})
})
expect(response).toHaveProperty('authenticated', false)
expect(response).toHaveProperty('email', '')
expect(response).toHaveProperty('is_staff', false)
})
it('should call local context greet with name parameter', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('greet', { name: 'World' })
})
expect(response).toHaveProperty('greeting', 'Hello, World!')
})
})
// ============================================================================
// Group 7: Error code coverage
// ============================================================================
describeIntegration('Error code coverage', () => {
it('should return NOT_IMPLEMENTED for NotImplementedError', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('not_implemented_fn', {})
} catch (e) {
error = e as DjangoError
}
})
expect(error).toBeInstanceOf(DjangoError)
expect(error!.code).toBe('NOT_IMPLEMENTED')
})
it('should return INTERNAL_ERROR for unhandled RuntimeError', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('buggy_fn', {})
} catch (e) {
error = e as DjangoError
}
})
expect(error).toBeInstanceOf(DjangoError)
expect(error!.code).toBe('INTERNAL_ERROR')
})
it('should return FORBIDDEN for PermissionError with wrong secret', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let error: DjangoError | null = null
await act(async () => {
try {
await result.current('permission_check_fn', { secret: 'wrong' })
} catch (e) {
error = e as DjangoError
}
})
expect(error).toBeInstanceOf(DjangoError)
expect(error!.code).toBe('FORBIDDEN')
expect(error!.isAuthError()).toBe(true)
})
it('should succeed with correct secret for permission_check_fn', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('permission_check_fn', { secret: 'open-sesame' })
})
expect(response).toEqual({ message: 'access granted' })
})
it('should return BAD_REQUEST for invalid JSON body', async () => {
const response = await fetch(`${BACKEND_URL}/api/djarea/call/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: '{not valid json}',
})
const data = await response.json()
expect(data.error).toBe(true)
expect(data.code).toBe('BAD_REQUEST')
})
it('should return BAD_REQUEST for missing fn field', async () => {
const response = await fetch(`${BACKEND_URL}/api/djarea/call/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ args: {} }),
})
const data = await response.json()
expect(data.error).toBe(true)
expect(data.code).toBe('BAD_REQUEST')
})
})
// ============================================================================
// Group 8: DjareaFormMixin integration
// ============================================================================
describeIntegration('DjareaFormMixin integration', () => {
it('should return schema with title, subtitle, and submit_label from DjareaFormMeta', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('contact.schema', { data: {} })
})
expect(response).toHaveProperty('fields')
const fields = response.fields
// Contact form should have name, email, and message fields
expect(fields).toHaveProperty('name')
expect(fields).toHaveProperty('email')
expect(fields).toHaveProperty('message')
// Meta should include title, subtitle, and submit_label
expect(response).toHaveProperty('meta')
expect(response.meta.title).toBe('Contact Us')
expect(response.meta).toHaveProperty('subtitle')
expect(response.meta.submit_label).toBe('Send Message')
})
it('should return form meta with live_validation and live_form_errors', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('contact.schema', { data: {} })
})
expect(response).toHaveProperty('meta')
expect(response.meta.live_validation).toBe(true)
expect(response.meta.live_form_errors).toBe(false)
})
it('should validate contact form and return field errors for missing fields', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('contact.validate', { data: {} })
})
expect(response.valid).toBe(false)
expect(response.errors).toBeInstanceOf(Array)
expect(response.errors.length).toBeGreaterThan(0)
// Should have errors for name, email, and message
const errorFieldNames = response.errors.map((e: any) => e.field)
expect(errorFieldNames).toContain('name')
expect(errorFieldNames).toContain('email')
expect(errorFieldNames).toContain('message')
})
it('should submit contact form successfully and get on_submit_success data', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('contact.submit', {
name: 'Test',
email: 'test@test.com',
message: 'Hello',
})
})
expect(response.success).toBe(true)
expect(response.data).toHaveProperty('received', true)
expect(response.data).toHaveProperty('from', 'test@test.com')
})
})
// ============================================================================
// Group 9: Formset integration
// ============================================================================
describeIntegration('Formset integration', () => {
it('should return formset schema for item form', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('item.formset.schema', { forms: [] })
})
expect(response).toHaveProperty('min_num')
expect(response).toHaveProperty('max_num')
})
it('should validate formset with invalid data', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('item.formset.validate', {
forms: [{ label: '', quantity: 0 }],
})
})
// Should have validation errors for the invalid form data
expect(response).toHaveProperty('errors')
})
it('should submit formset with valid data', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
let response: any = null
await act(async () => {
response = await result.current('item.formset.submit', {
forms: [{ label: 'Widget', quantity: 5 }],
})
})
expect(response).toHaveProperty('success', true)
})
})
// ============================================================================
// Group 10: Channel authorization
// ============================================================================
describeIntegration('Channel authorization', () => {
let connection: ChannelConnection
beforeEach(async () => {
connection = new ChannelConnection({ url: WS_URL, reconnect: false })
connection.connect()
await waitForConnected(connection)
})
afterEach(() => {
connection.disconnect()
})
it('should reject subscription to private channel when anonymous', async () => {
const msgPromise = new Promise<any>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Auth rejection timeout')), 5000)
connection.onMessage((msg) => {
if ('error' in msg) {
clearTimeout(timeout)
resolve(msg)
}
})
})
connection.send({ action: 'subscribe', channel: 'private' })
const msg = await msgPromise
expect(msg.error).toContain('Not authorized')
})
it('should successfully unsubscribe from a channel', async () => {
// First subscribe to chat
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Subscribe timeout')), 5000)
const unsub = connection.onMessage((msg) => {
if ('subscribed' in msg && msg.channel === 'chat') {
clearTimeout(timeout)
unsub()
resolve()
}
})
connection.send({
action: 'subscribe',
channel: 'chat',
params: { room: 'unsub-test' },
})
})
// Now unsubscribe
const unsubPromise = new Promise<any>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Unsubscribe timeout')), 5000)
const unsub = connection.onMessage((msg) => {
if ('unsubscribed' in msg && msg.channel === 'chat') {
clearTimeout(timeout)
unsub()
resolve(msg)
}
})
})
connection.send({
action: 'unsubscribe',
channel: 'chat',
params: { room: 'unsub-test' },
})
const unsubMsg = await unsubPromise
expect(unsubMsg).toHaveProperty('unsubscribed', true)
expect(unsubMsg).toHaveProperty('channel', 'chat')
})
})
// ============================================================================
// Group 11: WebSocket RPC happy path
// ============================================================================
describeIntegration('WebSocket RPC happy path', () => {
let connection: ChannelConnection
beforeEach(async () => {
connection = new ChannelConnection({ url: WS_URL, reconnect: false })
connection.connect()
await waitForConnected(connection)
})
afterEach(() => {
connection.disconnect()
})
it('should call echo via RPC and get correct response', async () => {
const response = await connection.rpc<{ text: string }, { message: string }>(
'echo',
{ text: 'ws rpc echo' }
)
expect(response).toHaveProperty('message')
expect(response.message).toContain('ws rpc echo')
})
it('should call add via RPC and get correct sum', async () => {
const response = await connection.rpc<{ a: number; b: number }, { result: number }>(
'add',
{ a: 100, b: 200 }
)
expect(response).toEqual({ result: 300 })
})
it('should reject multiply via RPC if not websocket-enabled', async () => {
// multiply uses @register_as which may not set websocket=True
// If it's HTTP-only, RPC should fail; if it supports WS, it should succeed
let response: any = null
let rpcError: RPCError | null = null
try {
response = await connection.rpc<{ x: number; y: number }, { product: number }>(
'multiply',
{ x: 7, y: 6 }
)
} catch (e) {
rpcError = e as RPCError
}
// Either it succeeds with the correct product, or it fails because it's HTTP-only
if (rpcError) {
expect(rpcError).toBeInstanceOf(RPCError)
} else {
expect(response).toEqual({ product: 42 })
}
})
it('should reject ws_whoami via RPC when anonymous', async () => {
let rpcError: RPCError | null = null
try {
await connection.rpc('ws_whoami', {})
} catch (e) {
rpcError = e as RPCError
}
expect(rpcError).toBeInstanceOf(RPCError)
expect(rpcError!.code).toBe('UNAUTHORIZED')
})
})
// ============================================================================
// Group 12: Successful form submit flow
// ============================================================================
describeIntegration('Form submit success flow', () => {
it('should sign up a new user via signup form', async () => {
const { result } = renderHook(() => useCall(), { wrapper: Wrapper })
// Use a unique email per run to avoid duplicate-user errors
const uniqueEmail = `newuser+${Date.now()}@example.com`
let response: any = null
await act(async () => {
response = await result.current('signup.submit', {
email: uniqueEmail,
password1: 'testpass123',
})
})
expect(response).toHaveProperty('success', true)
expect(response).toHaveProperty('data')
expect(response.data).toHaveProperty('user_id')
expect(typeof response.data.user_id).toBe('number')
})
})

View File

@@ -0,0 +1,11 @@
/**
* Re-export RouterAdapter from djarea/client.
*
* Allauth extends this with a required getParam method.
*/
import type { RouterAdapter as BaseRouterAdapter } from 'djarea/client'
export interface RouterAdapter extends BaseRouterAdapter {
/** Get a specific route param (e.g., from /auth/[...path]) - required for allauth */
getParam: (name: string) => string | string[] | undefined
}

309
react/src/allauth/api.ts Normal file
View File

@@ -0,0 +1,309 @@
import { OAuthProcess, apiURL } from './defines'
import {
type RegistrationResponseJSON,
type AuthenticationResponseJSON,
} from '@simplewebauthn/browser'
import type {
// Core types
AuthError,
User,
Flow,
Authenticated,
AuthenticationMeta,
// Request types
LoginRequest,
SignupRequest,
ProviderSignupRequest,
ReauthenticateRequest,
ChangePasswordRequest,
ResetPasswordRequest,
MFAAuthenticateRequest,
WebAuthnUpdateRequest,
// Response types
AllauthResponse,
AuthenticatedResponse,
AuthenticationRequiredResponse,
ReauthenticationRequiredResponse,
ConfigurationResponse,
EmailListResponse,
SessionListResponse,
AuthenticatorListResponse,
ProviderAccountListResponse,
TOTPStatusResponse,
RecoveryCodesResponse,
WebAuthnCreationOptionsResponse,
WebAuthnRequestOptionsResponse,
EmailVerificationInfoResponse,
ErrorResponse,
} from './types'
export type { AuthError } from './types'
// Registration = creating new credentials (signup, add)
// Authentication = verifying existing credentials (login, authenticate, reauthenticate)
type RegistrationCredential = RegistrationResponseJSON
type AuthenticationCredential = AuthenticationResponseJSON
/**
* Union of all possible auth responses
*/
export type AuthResponse =
| AuthenticatedResponse
| AuthenticationRequiredResponse
| ReauthenticationRequiredResponse
| ConfigurationResponse
| EmailListResponse
| SessionListResponse
| AuthenticatorListResponse
| ProviderAccountListResponse
| TOTPStatusResponse
| RecoveryCodesResponse
| WebAuthnCreationOptionsResponse
| WebAuthnRequestOptionsResponse
| EmailVerificationInfoResponse
| ErrorResponse
| AllauthResponse
export interface AuthDetails {
isAuthenticated: boolean
requiresReauthentication: boolean
user: User | null
pendingFlow: Flow | undefined
}
export const getAuthDetails = (auth: AllauthResponse | null | undefined): AuthDetails => {
const meta = auth?.meta as AuthenticationMeta | undefined
const isAuthenticated = !!auth && (auth?.status === 200 || (auth?.status === 401 && !!meta?.is_authenticated))
const requiresReauthentication = !!(isAuthenticated && auth?.status === 401)
const data = auth?.data as Authenticated | { flows?: Flow[]; user?: User } | undefined
const pendingFlow = (data as { flows?: Flow[] })?.flows?.find((flow: Flow) => flow.is_pending)
return {
isAuthenticated,
requiresReauthentication,
user: isAuthenticated ? (data as Authenticated)?.user ?? null : null,
pendingFlow
}
}
export type BrowserFormAction = (action: string, data: Record<string, string>) => void
type RequestFn = (method: string, path: string, data?: unknown, headers?: Record<string, string>) => Promise<AllauthResponse>
export const createAPI = (
request: RequestFn,
browserFormAction?: BrowserFormAction
) => {
return {
getConfig: async (): Promise<ConfigurationResponse | ErrorResponse> =>
await request('GET', apiURL.CONFIG) as ConfigurationResponse | ErrorResponse,
session: {
getStatus: async (): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
await request('GET', apiURL.SESSION) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
list: async (): Promise<SessionListResponse | ErrorResponse> =>
await request('GET', apiURL.SESSIONS) as SessionListResponse | ErrorResponse,
logout: async (): Promise<AllauthResponse> =>
await request('DELETE', apiURL.SESSION),
remove: async (ids: number[]): Promise<AllauthResponse> =>
await request('DELETE', apiURL.SESSIONS, { sessions: ids }),
},
account: {
signup: async (data: SignupRequest): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
await request('POST', apiURL.SIGNUP, data) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
login: async (data: LoginRequest): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
await request('POST', apiURL.LOGIN, data) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
reauthenticate: async (data: ReauthenticateRequest): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('POST', apiURL.REAUTHENTICATE, data) as AuthenticatedResponse | ErrorResponse,
emails: {
list: async (): Promise<EmailListResponse | ErrorResponse> =>
await request('GET', apiURL.EMAIL) as EmailListResponse | ErrorResponse,
add: async (email: string): Promise<EmailListResponse | ErrorResponse> =>
await request('POST', apiURL.EMAIL, { email }) as EmailListResponse | ErrorResponse,
remove: async (email: string): Promise<EmailListResponse | ErrorResponse> =>
await request('DELETE', apiURL.EMAIL, { email }) as EmailListResponse | ErrorResponse,
setPrimary: async (email: string): Promise<EmailListResponse | ErrorResponse> =>
await request('PATCH', apiURL.EMAIL, { email, primary: true }) as EmailListResponse | ErrorResponse,
verification: {
dispatch: async (email: string): Promise<AllauthResponse> =>
await request('PUT', apiURL.EMAIL, { email }),
checkKey: async (key: string): Promise<EmailVerificationInfoResponse | ErrorResponse> =>
await request('GET', apiURL.VERIFY_EMAIL, undefined, { 'X-Email-Verification-Key': key }) as EmailVerificationInfoResponse | ErrorResponse,
confirmKey: async (key: string): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('POST', apiURL.VERIFY_EMAIL, { key }) as AuthenticatedResponse | ErrorResponse,
}
},
password: {
set: async (data: ResetPasswordRequest): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('POST', apiURL.RESET_PASSWORD, data) as AuthenticatedResponse | ErrorResponse,
change: async (data: ChangePasswordRequest): Promise<AllauthResponse> =>
await request('POST', apiURL.CHANGE_PASSWORD, data),
reset: {
dispatch: async (email: string): Promise<AllauthResponse> =>
await request('POST', apiURL.REQUEST_PASSWORD_RESET, { email }),
checkKey: async (key: string): Promise<AllauthResponse> =>
await request('GET', apiURL.RESET_PASSWORD, undefined, { 'X-Password-Reset-Key': key }),
}
}
},
loginCodes: {
request: async (email: string): Promise<AuthenticationRequiredResponse | ErrorResponse> =>
await request('POST', apiURL.REQUEST_LOGIN_CODE, { email }) as AuthenticationRequiredResponse | ErrorResponse,
confirm: async (code: string): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
await request('POST', apiURL.CONFIRM_LOGIN_CODE, { code }) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
},
oauth: {
list: async (): Promise<ProviderAccountListResponse | ErrorResponse> =>
await request('GET', apiURL.PROVIDERS) as ProviderAccountListResponse | ErrorResponse,
signup: async (data: ProviderSignupRequest): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('POST', apiURL.PROVIDER_SIGNUP, data) as AuthenticatedResponse | ErrorResponse,
provider: (providerID: string) => {
const buildAuths = (processType: string) => {
return {
withToken: async (token: string): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
await request(
'POST',
apiURL.PROVIDER_TOKEN,
{
provider: providerID,
process: processType,
token: token,
}
) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
withRedirect: (endpoint: string): void => {
if (browserFormAction) {
if (!process.env.NEXT_PUBLIC_HOST_URL) {
throw new Error('NEXT_PUBLIC_HOST_URL environment variable is not set. OAuth redirects require this to be set at build time.')
}
browserFormAction(
apiURL.REDIRECT_TO_PROVIDER,
{
provider: providerID,
process: processType,
callback_url: new URL(`${process.env.NEXT_PUBLIC_HOST_URL}/${endpoint}`).toString(),
}
)
}
}
}
}
return {
removeFrom: async (accountUID: string): Promise<ProviderAccountListResponse | ErrorResponse> =>
await request('DELETE', apiURL.PROVIDERS, { provider: providerID, account: accountUID }) as ProviderAccountListResponse | ErrorResponse,
login: buildAuths(OAuthProcess.LOGIN),
connect: buildAuths(OAuthProcess.CONNECT),
}
}
},
mfa: {
list: async (): Promise<AuthenticatorListResponse | ErrorResponse> =>
await request('GET', apiURL.AUTHENTICATORS) as AuthenticatorListResponse | ErrorResponse,
authenticate: async (code: string): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('POST', apiURL.MFA_AUTHENTICATE, { code } as MFAAuthenticateRequest) as AuthenticatedResponse | ErrorResponse,
reauthenticate: async (code: string): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('POST', apiURL.MFA_REAUTHENTICATE, { code } as MFAAuthenticateRequest) as AuthenticatedResponse | ErrorResponse,
trust: async (trust: boolean): Promise<AllauthResponse> =>
await request('POST', apiURL.MFA_TRUST, { trust }),
totp: {
getStatus: async (): Promise<TOTPStatusResponse | ErrorResponse> =>
await request('GET', apiURL.TOTP_AUTHENTICATOR) as TOTPStatusResponse | ErrorResponse,
activate: async (code: string): Promise<TOTPStatusResponse | ErrorResponse> =>
await request('POST', apiURL.TOTP_AUTHENTICATOR, { code }) as TOTPStatusResponse | ErrorResponse,
deactivate: async (): Promise<AllauthResponse> =>
await request('DELETE', apiURL.TOTP_AUTHENTICATOR),
},
recoveryCodes: {
list: async (): Promise<RecoveryCodesResponse | ErrorResponse> =>
await request('GET', apiURL.RECOVERY_CODES) as RecoveryCodesResponse | ErrorResponse,
regenerate: async (): Promise<RecoveryCodesResponse | ErrorResponse> =>
await request('POST', apiURL.RECOVERY_CODES) as RecoveryCodesResponse | ErrorResponse,
}
},
webauthn: {
signup: async (name: string, credential: RegistrationCredential): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('PUT', apiURL.SIGNUP_WEBAUTHN, { name, credential }) as AuthenticatedResponse | ErrorResponse,
add: async (name: string, credential: RegistrationCredential): Promise<AllauthResponse> =>
await request('POST', apiURL.WEBAUTHN_AUTHENTICATOR, { name, credential }),
login: async (credential: AuthenticationCredential): Promise<AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse> =>
await request('POST', apiURL.LOGIN_WEBAUTHN, { credential }) as AuthenticatedResponse | AuthenticationRequiredResponse | ErrorResponse,
authenticate: async (credential: AuthenticationCredential): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('POST', apiURL.AUTHENTICATE_WEBAUTHN, { credential }) as AuthenticatedResponse | ErrorResponse,
reauthenticate: async (credential: AuthenticationCredential): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('POST', apiURL.REAUTHENTICATE_WEBAUTHN, { credential }) as AuthenticatedResponse | ErrorResponse,
update: async (id: number, data: Omit<WebAuthnUpdateRequest, 'id'>): Promise<AllauthResponse> =>
await request('PUT', apiURL.WEBAUTHN_AUTHENTICATOR, { id, ...data }),
delete: async (ids: number[]): Promise<AllauthResponse> =>
await request('DELETE', apiURL.WEBAUTHN_AUTHENTICATOR, { authenticators: ids }),
passkey: {
signup: async (email: string): Promise<AllauthResponse> =>
await request('POST', apiURL.SIGNUP_WEBAUTHN, { email }),
confirm: async (): Promise<AuthenticatedResponse | ErrorResponse> =>
await request('PUT', apiURL.SIGNUP_WEBAUTHN) as AuthenticatedResponse | ErrorResponse,
},
requestOptions: {
creation: async (passwordless: boolean): Promise<WebAuthnCreationOptionsResponse | ErrorResponse> =>
await request('GET', apiURL.WEBAUTHN_AUTHENTICATOR + (passwordless ? '?passwordless' : '')) as WebAuthnCreationOptionsResponse | ErrorResponse,
creationAtSignup: async (): Promise<WebAuthnCreationOptionsResponse | ErrorResponse> =>
await request('GET', apiURL.SIGNUP_WEBAUTHN) as WebAuthnCreationOptionsResponse | ErrorResponse,
login: async (): Promise<WebAuthnRequestOptionsResponse | ErrorResponse> =>
await request('GET', apiURL.LOGIN_WEBAUTHN) as WebAuthnRequestOptionsResponse | ErrorResponse,
authentication: async (): Promise<WebAuthnRequestOptionsResponse | ErrorResponse> =>
await request('GET', apiURL.AUTHENTICATE_WEBAUTHN) as WebAuthnRequestOptionsResponse | ErrorResponse,
reauthentication: async (): Promise<WebAuthnRequestOptionsResponse | ErrorResponse> =>
await request('GET', apiURL.REAUTHENTICATE_WEBAUTHN) as WebAuthnRequestOptionsResponse | ErrorResponse,
},
}
}
}
export type AllauthAPI = ReturnType<typeof createAPI>

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 'djarea'
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 Djarea 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 'djarea'
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 'djarea'
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'

View File

@@ -0,0 +1,67 @@
/**
* Configuration for the allauth library.
*
* This config serves two purposes:
* 1. Define the base path for Django-initiated routes (must match HEADLESS_FRONTEND_URLS)
* 2. Define where to navigate for various auth events (developer controls these)
*
* For JWT-based API calls, use djarea/jwt separately.
*/
export interface AllauthConfig {
/**
* Base path for Django-initiated routes (email verification, password reset, OAuth).
* This must match the base path configured in Django's HEADLESS_FRONTEND_URLS.
*
* Example: '/auth' means Django sends users to '/auth/verify-email/{key}'
*/
basePath: string
/**
* Navigation targets for auth events.
* These are the URLs/paths the developer wants users sent to.
*/
routes: {
/** Where to go after successful authentication */
authenticated: string
/** Where to go after logout */
logout: string
/** Where the login page is (for "Back to login" links) */
login: string
/** Where the signup page is (for "Create account" links) */
signup: string
}
}
export const defaultConfig: AllauthConfig = {
basePath: '/auth',
routes: {
authenticated: '/dashboard',
logout: '/',
login: '/login',
signup: '/signup',
},
}
/**
* Creates a config by merging provided options with defaults.
*/
export function createAllauthConfig(config: Partial<AllauthConfig>): AllauthConfig {
return {
basePath: config.basePath ?? defaultConfig.basePath,
routes: {
...defaultConfig.routes,
...config.routes,
},
}
}
/**
* Django-initiated flow paths (relative to basePath).
* These must match what's configured in Django's HEADLESS_FRONTEND_URLS.
*/
export const DjangoFlowPaths = {
VERIFY_EMAIL: '/verify-email',
RESET_PASSWORD: '/reset-password',
OAUTH_ERROR: '/oauth/error',
} as const

View File

@@ -0,0 +1,72 @@
'use client'
import { useMemo } from 'react'
import { useDjangoCSRClient, Auth } from 'djarea/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 'djarea/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 'djarea/client/react'
import { useDjarea, useDjareaContext } from 'djarea'
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 } = useDjarea()
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 DjareaProvider.
*
* This uses the generic djarea hook to access the 'user' context.
* The backend defines this context in lib/djarea/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 = useDjareaContext<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'

View File

@@ -0,0 +1,71 @@
export const OAuthProcess = {
LOGIN: 'login',
CONNECT: 'connect'
}
export const AuthenticatorType = {
TOTP: 'totp',
RECOVERY_CODES: 'recovery_codes',
WEBAUTHN: 'webauthn'
}
export const Flows = {
LOGIN: 'login',
LOGIN_BY_CODE: 'login_by_code',
MFA_AUTHENTICATE: 'mfa_authenticate',
MFA_REAUTHENTICATE: 'mfa_reauthenticate',
MFA_TRUST: 'mfa_trust',
MFA_WEBAUTHN_SIGNUP: 'mfa_signup_webauthn',
PASSWORD_RESET_BY_CODE: 'password_reset_by_code',
PROVIDER_REDIRECT: 'provider_redirect',
PROVIDER_SIGNUP: 'provider_signup',
REAUTHENTICATE: 'reauthenticate',
SIGNUP: 'signup',
VERIFY_EMAIL: 'verify_email',
}
export const apiURL = {
// Meta
CONFIG: '/config',
// Account management
CHANGE_PASSWORD: '/account/password/change',
EMAIL: '/account/email',
PROVIDERS: '/account/providers',
// Account management: 2FA
AUTHENTICATORS: '/account/authenticators',
RECOVERY_CODES: '/account/authenticators/recovery-codes',
TOTP_AUTHENTICATOR: '/account/authenticators/totp',
// Auth: Basics
LOGIN: '/auth/login',
REQUEST_LOGIN_CODE: '/auth/code/request',
CONFIRM_LOGIN_CODE: '/auth/code/confirm',
SESSION: '/auth/session',
REAUTHENTICATE: '/auth/reauthenticate',
REQUEST_PASSWORD_RESET: '/auth/password/request',
RESET_PASSWORD: '/auth/password/reset',
SIGNUP: '/auth/signup',
VERIFY_EMAIL: '/auth/email/verify',
// Auth: 2FA
MFA_AUTHENTICATE: '/auth/2fa/authenticate',
MFA_REAUTHENTICATE: '/auth/2fa/reauthenticate',
MFA_TRUST: '/auth/2fa/trust',
// Auth: Social
PROVIDER_SIGNUP: '/auth/provider/signup',
REDIRECT_TO_PROVIDER: '/auth/provider/redirect',
PROVIDER_TOKEN: '/auth/provider/token',
// Auth: Sessions
SESSIONS: '/auth/sessions',
// Auth: WebAuthn
REAUTHENTICATE_WEBAUTHN: '/auth/webauthn/reauthenticate',
AUTHENTICATE_WEBAUTHN: '/auth/webauthn/authenticate',
LOGIN_WEBAUTHN: '/auth/webauthn/login',
SIGNUP_WEBAUTHN: '/auth/webauthn/signup',
WEBAUTHN_AUTHENTICATOR: '/account/authenticators/webauthn'
}

View File

@@ -0,0 +1,51 @@
import { getAuthDetails } from './api'
import type { AllauthResponse, AuthenticationMethod } from './types'
export const AuthChangeEvent = {
LOGGED_OUT: 'LOGGED_OUT',
LOGGED_IN: 'LOGGED_IN',
REAUTHENTICATED: 'REAUTHENTICATED',
REAUTHENTICATION_REQUIRED: 'REAUTHENTICATION_REQUIRED',
FLOW_UPDATED: 'FLOW_UPDATED'
}
export default function getAuthChangeEvent(fromAuth: AllauthResponse, toAuth: AllauthResponse): string {
let before = getAuthDetails(fromAuth)
const after = getAuthDetails(toAuth)
if (toAuth.status === 410) {
return AuthChangeEvent.LOGGED_OUT
}
const shouldReauth = () => {
const fromMethods = (fromAuth.data?.methods as AuthenticationMethod[] | undefined) ?? []
const toMethods = (toAuth.data?.methods as AuthenticationMethod[] | undefined) ?? []
return (before.requiresReauthentication) || (fromMethods.length < toMethods.length)
}
// Corner case: user ID change. Treat as if we're transitioning from anonymous state.
if (before.user && after.user && before.user?.id !== after.user?.id) {
before = { isAuthenticated: false, requiresReauthentication: false, user: null, pendingFlow: undefined }
}
if (!before.isAuthenticated && after.isAuthenticated) {
return AuthChangeEvent.LOGGED_IN
} else if (before.isAuthenticated && !after.isAuthenticated) {
return AuthChangeEvent.LOGGED_OUT
} else if (before.isAuthenticated && after.isAuthenticated) {
if (after.requiresReauthentication) {
return AuthChangeEvent.REAUTHENTICATION_REQUIRED
} else if (shouldReauth()) {
return AuthChangeEvent.REAUTHENTICATED
}
} else if (!before.isAuthenticated && !after.isAuthenticated) {
const fromFlow = before.pendingFlow
const toFlow = after.pendingFlow
if (toFlow?.id && fromFlow?.id !== toFlow.id) {
return AuthChangeEvent.FLOW_UPDATED
}
}
// No change.
return ''
}

View File

@@ -0,0 +1,48 @@
import type { DjangoHTTPClient } from 'djarea/client'
import { createAPI } from './api'
import type { AllauthResponse } from './types'
export interface InitialAuth {
config: AllauthResponse
auth: AllauthResponse
}
/**
* Fetch initial allauth state using an SSR client.
* Call this in a server component and pass the result to AllauthContext.
*
* Note: User data comes from DjangoContext (which should wrap AllauthContext).
* Use getDjangoHydration() from generated.contexts for that.
*
* @param ssrClient - A server-side Django HTTP client (e.g., createDjangoSSRClient)
*/
export async function getInitialAuth(
ssrClient: DjangoHTTPClient,
): Promise<InitialAuth> {
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
const resp = await ssrClient.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)
)
try {
const [config, auth] = await Promise.all([
api.getConfig(),
api.session.getStatus(),
])
return { config, auth }
} catch (e) {
console.error('[getInitialAuth] Failed to fetch initial auth:', e)
return {
config: { status: 200, data: {} },
auth: { status: 401, data: {} },
}
}
}

213
react/src/allauth/index.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* djarea/allauth
*
* React integration for django-allauth headless API.
* Framework-agnostic - works with Next.js, Remix, React Router, etc.
*
* ## Quick Start (Next.js)
*
* ```tsx
* // layout.tsx
* import { cookies } from 'next/headers'
* import { createDjangoSSRClient } from 'djarea/client'
* import { getInitialAuth } from 'djarea/allauth'
* import { NextAllauthContext } from 'djarea/allauth/nextjs'
*
* export default async function RootLayout({ children }) {
* const ssrClient = createDjangoSSRClient({ cookies: await cookies() })
* const hydration = await getInitialAuth(ssrClient)
*
* return (
* <NextAllauthContext hydration={hydration}>
* {children}
* </NextAllauthContext>
* )
* }
* ```
*
* ## Without SSR (pure client-side)
*
* ```tsx
* // Just omit hydration - AllauthContext will fetch client-side
* <NextAllauthContext>
* {children}
* </NextAllauthContext>
* ```
*/
// Configuration
export { createAllauthConfig, defaultConfig, DjangoFlowPaths } from './config'
export type { AllauthConfig } from './config'
// Hydration
export { getInitialAuth } from './hydration'
export type { InitialAuth } from './hydration'
// Providers
export { AllauthContext } from './contexts/AllauthContext'
export type { AllauthContextProps } from './contexts/AllauthContext'
// Router adapter
export type { RouterAdapter } from './adapters/router'
export { useRouter } from './contexts/RouterContext'
// Hooks
export { useAuthContext, useAuth, useUser, useConfig, useFeatures } from './contexts/AuthContext'
export { useAllauthAPI } from './contexts/APIContext'
export { useAllauthConfig } from './contexts/ConfigContext'
export { useStyles, cx } from './contexts/StylesContext'
// Styling
export type { AuthClassNames } from './styles/types'
// Components
export {
// Main UI component (SPA - handles login, signup, MFA, settings, logout)
AllauthUI,
// Django-initiated flow handler (email verification, password reset links, OAuth)
AllauthRouter,
// Settings
AuthSettings,
ProfileSection,
EmailsSection,
PasswordSection,
PasskeysSection,
ConnectionsSection,
MFASection,
SessionsSection,
SettingsSection,
SettingsItem,
SettingsList,
Badge,
Button,
// Individual auth views
LoginView,
SignupView,
MFAChooserView,
MFAWebAuthnView,
MFATOTPView,
MFARecoveryCodesView,
// Building blocks
AuthCard,
AuthFormPage,
AuthDjangoForm,
PasskeyLogin,
ProviderList,
// Form utilities
useAuthForm,
AuthField,
} from './components'
export type { AllauthUIView, AllauthUIMode } from './components'
// Routing guards
export { UserRoute, StaffRoute, AnonymousRoute, FeatureRoute } from './routing'
// API
export { createAPI, getAuthDetails } from './api'
export type { AuthResponse, AuthDetails, AllauthAPI, BrowserFormAction } from './api'
// Types (re-exported from types.ts)
export type {
// Primitive types
Timestamp,
Email,
Phone,
Username,
Password,
Code,
AuthenticatorCode,
ProviderID,
ProviderAccountID,
AuthenticatorID,
ClientID,
// Enums
AuthenticatorType as AuthenticatorTypeEnum,
FlowID,
LoginMethod,
OAuthProcess,
ProviderFlow,
// User & Session
User,
Session,
EmailAddress,
PhoneNumber,
// Authentication
Flow,
AuthenticationMethod,
Authenticated,
ReauthenticationRequired,
// Provider
Provider,
ProviderAccount,
// MFA / Authenticator
BaseAuthenticator,
TOTPAuthenticator,
RecoveryCodesAuthenticator,
SensitiveRecoveryCodesAuthenticator,
WebAuthnAuthenticator,
Authenticator,
// Configuration
AccountConfiguration,
SocialAccountConfiguration,
MFAConfiguration,
UserSessionsConfiguration,
AllauthConfiguration,
// WebAuthn
WebAuthnPublicKeyCredentialCreationOptions,
WebAuthnPublicKeyCredentialRequestOptions,
WebAuthnCreationOptions,
WebAuthnRequestOptions,
// TOTP
TOTPStatus,
// Meta
BaseAuthenticationMeta,
AuthenticationMeta,
AuthenticatedMeta,
// Response types
AuthError,
AllauthResponse,
AuthenticatedResponse,
ConfigurationResponse,
EmailListResponse,
SessionListResponse,
AuthenticatorListResponse,
ProviderAccountListResponse,
TOTPStatusResponse,
RecoveryCodesResponse,
WebAuthnCreationOptionsResponse,
WebAuthnRequestOptionsResponse,
EmailVerificationInfoResponse,
AuthenticationRequiredResponse,
ReauthenticationRequiredResponse,
ErrorResponse,
ForbiddenResponse,
ConflictResponse,
SessionGoneResponse,
// Request types
LoginRequest,
SignupRequest,
ProviderSignupRequest,
ReauthenticateRequest,
RequestLoginCodeRequest,
ConfirmLoginCodeRequest,
MFAAuthenticateRequest,
MFATrustRequest,
RequestPasswordResetRequest,
ResetPasswordRequest,
VerifyEmailRequest,
ChangePasswordRequest,
AddEmailRequest,
ProviderRedirectRequest,
ProviderTokenRequest,
WebAuthnAddRequest,
WebAuthnAuthenticateRequest,
WebAuthnUpdateRequest,
WebAuthnDeleteRequest,
EndSessionsRequest,
// Union types
AuthResponse as AuthResponseUnion,
SessionStatusResponse,
} from './types'
// Constants
export { Flows, AuthenticatorType } from './defines'

View File

@@ -0,0 +1,96 @@
'use client'
/**
* Next.js adapter for djarea/allauth.
*
* Usage:
* ```tsx
* // In layout.tsx (server component)
* import { createDjangoSSRClient } from 'djarea/client'
* import { getInitialAuth } from 'djarea/allauth'
* import { NextAllauthContext } from 'djarea/allauth/nextjs'
*
* export default async function RootLayout({ children }) {
* const ssrClient = createDjangoSSRClient({ cookies: await cookies() })
* const hydration = await getInitialAuth(ssrClient)
*
* return (
* <NextAllauthContext hydration={hydration}>
* {children}
* </NextAllauthContext>
* )
* }
* ```
*/
import { ReactNode } from 'react'
import { useRouter, usePathname, useSearchParams, useParams } from 'next/navigation'
import type { RouterAdapter } from './adapters/router'
import type { InitialAuth } from './hydration'
import { AllauthContext } from './contexts/AllauthContext'
import { AllauthConfig } from './config'
import { AuthClassNames } from './styles/types'
/**
* Create a RouterAdapter from Next.js App Router hooks.
*/
export function useNextRouter(): RouterAdapter {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const params = useParams()
return {
push: (path: string) => router.push(path),
replace: (path: string) => router.replace(path),
pathname,
searchParams: new URLSearchParams(searchParams.toString()),
getParam: (name: string) => params[name] as string | string[] | undefined,
}
}
export interface NextAllauthContextProps {
children: ReactNode
/** 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
}
/**
* Next.js-specific AllauthContext that handles the router automatically.
*
* IMPORTANT: Must be wrapped by DjangoContext which provides user data.
*
* ```tsx
* <DjangoContext client={client} hydration={djangoHydration}>
* <NextAllauthContext hydration={allauthHydration}>
* {children}
* </NextAllauthContext>
* </DjangoContext>
* ```
*/
export function NextAllauthContext({
children,
hydration,
allauthConfig,
classNames,
}: NextAllauthContextProps) {
const router = useNextRouter()
return (
<AllauthContext
hydration={hydration}
router={router}
allauthConfig={allauthConfig}
classNames={classNames}
>
{children}
</AllauthContext>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from './contexts/RouterContext'
import { useAllauthConfig } from './contexts/ConfigContext'
import { useAuth, useUser, useConfig } from './contexts/AuthContext'
/**
* Route guard that only renders children if the user is authenticated.
* Redirects to login page if not authenticated.
*/
export function UserRoute({ children }: { children: React.ReactNode }) {
const router = useRouter()
const config = useAllauthConfig()
const { isAuthenticated } = useAuth()
useEffect(() => {
if (!isAuthenticated) {
const next = encodeURIComponent(router.pathname + router.searchParams.toString())
router.replace(`${config.routes.login}?next=${next}`)
}
}, [isAuthenticated, router, config.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: React.ReactNode }) {
const router = useRouter()
const config = useAllauthConfig()
const { isAuthenticated } = useAuth()
const user = useUser()
useEffect(() => {
if (!isAuthenticated) {
const next = encodeURIComponent(router.pathname + router.searchParams.toString())
router.replace(`${config.routes.login}?next=${next}`)
} else if (!user.is_staff) {
router.replace(config.routes.authenticated)
}
}, [isAuthenticated, user.is_staff, router, config.routes])
if (!isAuthenticated || !user.is_staff) 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: React.ReactNode }) {
const router = useRouter()
const config = useAllauthConfig()
const { isAuthenticated } = useAuth()
useEffect(() => {
if (isAuthenticated) {
router.replace(config.routes.authenticated)
}
}, [isAuthenticated, config.routes.authenticated, router])
if (isAuthenticated) return null
return children
}
/**
* Route guard that checks if a feature is enabled in the allauth config.
* Redirects to fallback if feature is disabled.
*/
type FeatureKey = 'signup' | 'login_by_code' | 'mfa' | 'socialaccount'
function isFeatureEnabled(config: any, feature: FeatureKey): boolean | undefined {
if (!config?.data) return undefined
switch (feature) {
case 'signup': return config.data.account?.is_open_for_signup
case 'login_by_code': return config.data.account?.login_by_code_enabled
case 'mfa': return config.data.mfa !== undefined
case 'socialaccount': return (config.data.socialaccount?.providers?.length ?? 0) > 0
}
}
export function FeatureRoute({
children,
feature,
redirectTo,
}: {
children: React.ReactNode
feature: FeatureKey
redirectTo?: string
}) {
const router = useRouter()
const allauthConfig = useConfig()
const config = useAllauthConfig()
const enabled = isFeatureEnabled(allauthConfig, feature)
const fallback = redirectTo ?? config.routes.login
useEffect(() => {
if (allauthConfig && enabled === false) {
router.replace(fallback)
}
}, [allauthConfig, enabled, fallback, router])
if (!allauthConfig || enabled === false) return null
return children
}

View File

@@ -0,0 +1,122 @@
/**
* Class names for styling the auth components.
*
* All properties are optional - components will use empty strings as defaults.
* Pass your own CSS module or Tailwind classes to customize the appearance.
*
* @example
* ```tsx
* // With CSS Modules
* import styles from './auth.module.css'
* <AllauthProvider classNames={styles}>
*
* // With Tailwind
* const classNames = {
* container: 'max-w-md mx-auto p-4',
* card: 'bg-white rounded-lg shadow-lg p-6',
* title: 'text-2xl font-bold',
* // ...
* }
* <AllauthProvider classNames={classNames}>
* ```
*/
export interface AuthClassNames {
// Layout
container?: string
card?: string
// Typography
title?: string
subtitle?: string
// Form elements
form?: string
fieldsContainer?: string
field?: string
fieldLabel?: string
fieldInput?: string
fieldHelp?: string
fieldError?: string
required?: string
// Buttons
submit?: string
link?: string
smallButton?: string
smallButtonPrimary?: string
smallButtonSecondary?: string
smallButtonDanger?: string
// Feedback
error?: string
success?: string
loading?: string
spinner?: string
emptyState?: string
// Divider
divider?: string
dividerText?: string
// Footer
footer?: string
// Code input (for TOTP/login codes)
codeInput?: string
// OAuth providers
providersContainer?: string
providerButtons?: string
providerButton?: string
// Passkey
passkeyContainer?: string
passkeyButton?: string
// Settings page
settingsContainer?: string
settingsPageTitle?: string
settingsCard?: string
settingsSection?: string
settingsSectionTitle?: string
settingsSubtitle?: string
settingsList?: string
settingsItem?: string
settingsItemInfo?: string
settingsItemLabel?: string
settingsItemMeta?: string
settingsItemActions?: string
// Badges
badge?: string
badgePrimary?: string
badgeUnverified?: string
badgeSuccess?: string
badgeDanger?: string
// Inline form
inlineForm?: string
// TOTP setup
totpSetup?: string
qrCode?: string
// Recovery codes
recoveryCodes?: string
// Form controls
checkbox?: string
radioGroup?: string
radioItem?: string
// Navigation
navLinks?: string
navLink?: string
navLinkActive?: string
}
/**
* Empty class names - used as default when no styles provided.
* Components will render without any styling classes.
*/
export const emptyClassNames: AuthClassNames = {}

546
react/src/allauth/types.ts Normal file
View File

@@ -0,0 +1,546 @@
/**
* TypeScript types for django-allauth headless API
* Generated from OpenAPI specification
*/
// =============================================================================
// Primitive Types
// =============================================================================
/** Epoch-based timestamp (use: new Date(value * 1000)) */
export type Timestamp = number
/** Email address */
export type Email = string
/** Phone number */
export type Phone = string
/** Username */
export type Username = string
/** Password */
export type Password = string
/** One-time code */
export type Code = string
/** Authenticator code (e.g., TOTP) */
export type AuthenticatorCode = string
/** Provider ID (e.g., "google", "github") */
export type ProviderID = string
/** Provider-specific account ID */
export type ProviderAccountID = string
/** Authenticator ID */
export type AuthenticatorID = number
/** OAuth client ID */
export type ClientID = string
// =============================================================================
// Enums
// =============================================================================
export type AuthenticatorType = 'recovery_codes' | 'totp' | 'webauthn'
export type FlowID =
| 'login'
| 'login_by_code'
| 'mfa_authenticate'
| 'mfa_reauthenticate'
| 'provider_redirect'
| 'provider_signup'
| 'provider_token'
| 'reauthenticate'
| 'signup'
| 'verify_email'
| 'verify_phone'
export type LoginMethod = 'email' | 'username'
export type OAuthProcess = 'login' | 'connect'
export type ProviderFlow = 'provider_redirect' | 'provider_token'
// =============================================================================
// User & Session Types
// =============================================================================
export interface User {
id?: number
display: string
email?: string
username?: string
has_usable_password: boolean
}
export interface Session {
id: number
user_agent: string
ip: string
created_at: Timestamp
last_seen_at?: Timestamp
is_current: boolean
}
export interface EmailAddress {
email: Email
primary: boolean
verified: boolean
}
export interface PhoneNumber {
phone: Phone
verified: boolean
}
// =============================================================================
// Authentication Types
// =============================================================================
export interface Flow {
id: FlowID
is_pending?: true
provider?: Provider
/** MFA types available (for mfa_authenticate/mfa_reauthenticate flows) */
types?: AuthenticatorType[]
}
export interface AuthenticationMethod {
method: 'password' | 'password_reset' | 'code' | 'socialaccount' | 'mfa'
at: Timestamp
email?: Email
phone?: Phone
username?: Username
provider?: ProviderID
uid?: ProviderAccountID
type?: AuthenticatorType
reauthenticated?: boolean
}
export interface Authenticated {
user: User
methods: AuthenticationMethod[]
}
export interface ReauthenticationRequired {
flows: Flow[]
user: User
methods: AuthenticationMethod[]
}
// =============================================================================
// Provider Types
// =============================================================================
export interface Provider {
id: ProviderID
name: string
client_id?: ClientID
openid_configuration_url?: string
flows: ProviderFlow[]
}
export interface ProviderAccount {
uid: ProviderAccountID
display: string
provider: Provider
}
// =============================================================================
// MFA / Authenticator Types
// =============================================================================
export interface BaseAuthenticator {
created_at: Timestamp
last_used_at: Timestamp | null
}
export interface TOTPAuthenticator extends BaseAuthenticator {
type: 'totp'
}
export interface RecoveryCodesAuthenticator extends BaseAuthenticator {
type: 'recovery_codes'
total_code_count: number
unused_code_count: number
}
export interface SensitiveRecoveryCodesAuthenticator extends RecoveryCodesAuthenticator {
unused_codes: AuthenticatorCode[]
}
export interface WebAuthnAuthenticator extends BaseAuthenticator {
type: 'webauthn'
id: AuthenticatorID
name: string
is_passwordless?: boolean
}
export type Authenticator = TOTPAuthenticator | RecoveryCodesAuthenticator | WebAuthnAuthenticator
// =============================================================================
// Configuration Types
// =============================================================================
export interface AccountConfiguration {
login_methods?: LoginMethod[]
is_open_for_signup: boolean
email_verification_by_code_enabled: boolean
login_by_code_enabled: boolean
password_reset_by_code_enabled?: boolean
}
export interface SocialAccountConfiguration {
providers: Provider[]
}
export interface MFAConfiguration {
supported_types: AuthenticatorType[]
passkey_login_enabled?: boolean
}
export interface UserSessionsConfiguration {
track_activity: boolean
}
export interface AllauthConfiguration {
account: AccountConfiguration
socialaccount?: SocialAccountConfiguration
mfa?: MFAConfiguration
usersessions?: UserSessionsConfiguration
}
// =============================================================================
// WebAuthn Types
// =============================================================================
export interface WebAuthnPublicKeyCredentialCreationOptions {
challenge: string
rp: {
name: string
id: string
}
user: {
id: string
name: string
displayName: string
}
pubKeyCredParams: Array<{
type: 'public-key'
alg: number
}>
timeout?: number
excludeCredentials?: Array<{
type: 'public-key'
id: string
}>
authenticatorSelection?: {
authenticatorAttachment?: 'platform' | 'cross-platform'
requireResidentKey?: boolean
residentKey?: 'discouraged' | 'preferred' | 'required'
userVerification?: 'required' | 'preferred' | 'discouraged'
}
attestation?: 'none' | 'indirect' | 'direct' | 'enterprise'
}
export interface WebAuthnPublicKeyCredentialRequestOptions {
challenge: string
rpId: string
allowCredentials?: Array<{
type: 'public-key'
id: string
}>
userVerification?: 'required' | 'preferred' | 'discouraged'
timeout?: number
}
export interface WebAuthnCreationOptions {
creation_options: {
publicKey: WebAuthnPublicKeyCredentialCreationOptions
}
}
export interface WebAuthnRequestOptions {
request_options: {
publicKey: WebAuthnPublicKeyCredentialRequestOptions
}
}
// =============================================================================
// TOTP Types
// =============================================================================
export interface TOTPStatus {
type: 'totp'
created_at: Timestamp
last_used_at: Timestamp | null
/** Base32-encoded secret (only present when not yet activated) */
secret?: string
/** TOTP URI for QR code generation */
totp_url?: string
}
// =============================================================================
// API Response Meta Types
// =============================================================================
export interface BaseAuthenticationMeta {
/** Session token (app clients only) */
session_token?: string
/** Access token (app clients only) */
access_token?: string
}
export interface AuthenticationMeta extends BaseAuthenticationMeta {
is_authenticated: boolean
}
export interface AuthenticatedMeta extends BaseAuthenticationMeta {
is_authenticated: true
}
// =============================================================================
// API Response Types
// =============================================================================
export interface AuthError {
code: string
message: string
param?: string
}
/** Base response structure - uses `any` for data/meta to maintain flexibility in generic use */
export interface AllauthResponse<TData = any, TMeta = any> {
status: number
data?: TData
meta?: TMeta
errors?: AuthError[]
}
/** 200 OK - Authenticated */
export interface AuthenticatedResponse extends AllauthResponse<Authenticated, AuthenticationMeta> {
status: 200
data: Authenticated
meta: AuthenticationMeta
}
/** 200 OK - Configuration */
export interface ConfigurationResponse extends AllauthResponse<AllauthConfiguration> {
status: 200
data: AllauthConfiguration
}
/** 200 OK - Email list */
export interface EmailListResponse extends AllauthResponse<EmailAddress[]> {
status: 200
data: EmailAddress[]
}
/** 200 OK - Session list */
export interface SessionListResponse extends AllauthResponse<Session[]> {
status: 200
data: Session[]
}
/** 200 OK - Authenticator list */
export interface AuthenticatorListResponse extends AllauthResponse<Authenticator[]> {
status: 200
data: Authenticator[]
}
/** 200 OK - Provider account list */
export interface ProviderAccountListResponse extends AllauthResponse<ProviderAccount[]> {
status: 200
data: ProviderAccount[]
}
/** 200 OK - TOTP status */
export interface TOTPStatusResponse extends AllauthResponse<TOTPStatus> {
status: 200
data: TOTPStatus
}
/** 200 OK - Recovery codes */
export interface RecoveryCodesResponse extends AllauthResponse<SensitiveRecoveryCodesAuthenticator> {
status: 200
data: SensitiveRecoveryCodesAuthenticator
}
/** 200 OK - WebAuthn creation options */
export interface WebAuthnCreationOptionsResponse extends AllauthResponse<WebAuthnCreationOptions> {
status: 200
data: WebAuthnCreationOptions
}
/** 200 OK - WebAuthn request options */
export interface WebAuthnRequestOptionsResponse extends AllauthResponse<WebAuthnRequestOptions> {
status: 200
data: WebAuthnRequestOptions
}
/** 200 OK - Email verification info */
export interface EmailVerificationInfoResponse extends AllauthResponse<{ email: Email; user: User }> {
status: 200
data: { email: Email; user: User }
}
/** 401 - Authentication required (not authenticated) */
export interface AuthenticationRequiredResponse extends AllauthResponse<{ flows: Flow[] }, AuthenticationMeta> {
status: 401
data: { flows: Flow[] }
meta: AuthenticationMeta & { is_authenticated: false }
}
/** 401 - Reauthentication required (authenticated but needs reauthentication) */
export interface ReauthenticationRequiredResponse extends AllauthResponse<ReauthenticationRequired, AuthenticatedMeta> {
status: 401
data: ReauthenticationRequired
meta: AuthenticatedMeta
}
/** 400 - Bad request / validation error */
export interface ErrorResponse extends AllauthResponse<never> {
status: 400
errors: AuthError[]
}
/** 403 - Forbidden */
export interface ForbiddenResponse extends AllauthResponse<never> {
status: 403
}
/** 409 - Conflict */
export interface ConflictResponse extends AllauthResponse<never> {
status: 409
}
/** 410 - Session gone/expired */
export interface SessionGoneResponse extends AllauthResponse<Record<string, never>, AuthenticationMeta> {
status: 410
data: Record<string, never>
meta: AuthenticationMeta
}
// =============================================================================
// API Request Types
// =============================================================================
export interface LoginRequest {
email?: Email
username?: Username
phone?: Phone
password: Password
}
export interface SignupRequest {
email: Email
password: Password
[key: string]: unknown // Additional custom signup fields
}
export interface ProviderSignupRequest {
email: Email
[key: string]: unknown
}
export interface ReauthenticateRequest {
password: Password
}
export interface RequestLoginCodeRequest {
email?: Email
phone?: Phone
}
export interface ConfirmLoginCodeRequest {
code: Code
}
export interface MFAAuthenticateRequest {
code: AuthenticatorCode
}
export interface MFATrustRequest {
trust: boolean
}
export interface RequestPasswordResetRequest {
email: Email
}
export interface ResetPasswordRequest {
key: string
password: Password
}
export interface VerifyEmailRequest {
key: string
}
export interface ChangePasswordRequest {
current_password?: Password
new_password: Password
}
export interface AddEmailRequest {
email: Email
}
export interface ProviderRedirectRequest {
provider: ProviderID
process: OAuthProcess
callback_url: string
}
export interface ProviderTokenRequest {
provider: ProviderID
process: OAuthProcess
token: {
client_id: ClientID
id_token?: string
access_token?: string
}
}
export interface WebAuthnAddRequest {
name: string
credential: unknown // WebAuthn RegistrationResponseJSON
}
export interface WebAuthnAuthenticateRequest {
credential: unknown // WebAuthn AuthenticationResponseJSON
}
export interface WebAuthnUpdateRequest {
id: AuthenticatorID
name?: string
}
export interface WebAuthnDeleteRequest {
authenticators: AuthenticatorID[]
}
export interface EndSessionsRequest {
sessions: number[]
}
// =============================================================================
// Union Types for Responses
// =============================================================================
/** Possible responses from authentication endpoints */
export type AuthResponse =
| AuthenticatedResponse
| AuthenticationRequiredResponse
| ReauthenticationRequiredResponse
| ErrorResponse
/** Possible responses from session status endpoint */
export type SessionStatusResponse =
| AuthenticatedResponse
| AuthenticationRequiredResponse
| SessionGoneResponse

View File

@@ -0,0 +1,165 @@
/**
* Tests for ChannelConnection
*
* These tests verify the ChannelConnection class API.
* Unit tests for class structure don't require a real backend.
* Integration tests for actual WebSocket connections require the backend.
*
* Backend must be running for integration tests: docker-compose up
*/
import { ChannelConnection, RPCError } from '../connection'
import { describeIntegration, WS_URL } from '../../testing'
describe('ChannelConnection (unit tests)', () => {
describe('construction', () => {
it('should start in disconnected state', () => {
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
expect(connection.status).toBe('disconnected')
})
})
describe('status change handlers', () => {
it('should allow subscribing to status changes', () => {
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
const handler = jest.fn()
const unsubscribe = connection.onStatusChange(handler)
expect(typeof unsubscribe).toBe('function')
})
})
describe('message handlers', () => {
it('should allow subscribing to messages', () => {
const connection = new ChannelConnection({ url: 'ws://localhost/ws/' })
const handler = jest.fn()
const unsubscribe = connection.onMessage(handler)
expect(typeof unsubscribe).toBe('function')
})
})
describe('send queueing', () => {
it('should queue messages when not connected', () => {
const connection = new ChannelConnection({
url: 'ws://localhost/ws/',
reconnect: false,
})
// This shouldn't throw
connection.send({
action: 'subscribe',
channel: 'test',
params: {},
})
// Status should still be disconnected (or connecting if it auto-connected)
expect(['disconnected', 'connecting']).toContain(connection.status)
})
})
describe('rpc', () => {
it('should queue rpc messages when not connected', () => {
const connection = new ChannelConnection({
url: 'ws://localhost/ws/',
reconnect: false,
})
const promise = connection.rpc('test_fn', { arg: 'value' })
expect(promise).toBeInstanceOf(Promise)
})
})
})
describeIntegration('ChannelConnection (integration)', () => {
describe('real WebSocket connection', () => {
it('should connect to real backend WebSocket', async () => {
const connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
const statusChanges: string[] = []
connection.onStatusChange((status) => {
statusChanges.push(status)
})
// Connect
connection.connect()
// Wait for connection
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'))
}, 5000)
const unsubscribe = connection.onStatusChange((status) => {
if (status === 'connected') {
clearTimeout(timeout)
unsubscribe()
resolve()
}
})
})
expect(connection.status).toBe('connected')
// Cleanup
connection.disconnect()
})
it('should disconnect cleanly', async () => {
const connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
// Connect first
connection.connect()
await new Promise<void>((resolve) => {
const unsubscribe = connection.onStatusChange((status) => {
if (status === 'connected') {
unsubscribe()
resolve()
}
})
})
// Now disconnect
connection.disconnect()
// Should be disconnected
expect(connection.status).toBe('disconnected')
})
})
})
describe('RPCError', () => {
it('should be an Error subclass', () => {
const error = new RPCError('TEST_CODE', 'Test message')
expect(error).toBeInstanceOf(Error)
expect(error).toBeInstanceOf(RPCError)
})
it('should have correct properties', () => {
const error = new RPCError('VALIDATION_ERROR', 'Field is required', { field: 'email' })
expect(error.code).toBe('VALIDATION_ERROR')
expect(error.message).toBe('Field is required')
expect(error.details).toEqual({ field: 'email' })
expect(error.name).toBe('RPCError')
})
it('should work without details', () => {
const error = new RPCError('NOT_FOUND', 'Function not found')
expect(error.code).toBe('NOT_FOUND')
expect(error.message).toBe('Function not found')
expect(error.details).toBeUndefined()
})
})

View File

@@ -0,0 +1,207 @@
/**
* Tests for ChannelProvider context
*
* Unit tests run without backend.
* Integration tests require: docker-compose up
*
* Run integration tests with: RUN_INTEGRATION_TESTS=true npm run test
*/
import { renderHook, act, waitFor } from '@testing-library/react'
import { ReactNode } from 'react'
import { ChannelProvider, useChannelContext, useChannelStatus } from '../context'
import { ChannelConnection } from '../connection'
import { describeIntegration, WS_URL } from '../../testing'
// ============================================================================
// Unit Tests (no backend required)
// ============================================================================
describe('ChannelProvider (unit)', () => {
describe('useChannelContext', () => {
it('should throw when used outside ChannelProvider', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
renderHook(() => useChannelContext())
}).toThrow('useChannelContext must be used within a ChannelProvider')
consoleSpy.mockRestore()
})
it('should return connection and status when inside provider', () => {
const connection = new ChannelConnection({
url: 'ws://localhost/ws/',
reconnect: false,
})
const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelProvider connection={connection} autoConnect={false}>
{children}
</ChannelProvider>
)
const { result } = renderHook(() => useChannelContext(), { wrapper })
expect(result.current.connection).toBe(connection)
expect(result.current.status).toBe('disconnected')
connection.disconnect()
})
})
describe('useChannelStatus', () => {
it('should return disconnected when autoConnect is false', () => {
const connection = new ChannelConnection({
url: 'ws://localhost/ws/',
reconnect: false,
})
const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelProvider connection={connection} autoConnect={false}>
{children}
</ChannelProvider>
)
const { result } = renderHook(() => useChannelStatus(), { wrapper })
expect(result.current).toBe('disconnected')
connection.disconnect()
})
})
})
// ============================================================================
// Integration Tests (require running backend)
// ============================================================================
describeIntegration('ChannelProvider (integration)', () => {
describe('with real WebSocket connection', () => {
let connection: ChannelConnection
beforeEach(() => {
connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
})
afterEach(() => {
connection.disconnect()
})
const createWrapper = (autoConnect = true) => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider
connection={connection}
autoConnect={autoConnect}
>
{children}
</ChannelProvider>
)
}
}
it('should auto-connect when autoConnect is true', async () => {
const { result } = renderHook(() => useChannelContext(), {
wrapper: createWrapper(true),
})
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
})
it('should not auto-connect when autoConnect is false', () => {
const { result } = renderHook(() => useChannelContext(), {
wrapper: createWrapper(false),
})
expect(result.current.status).toBe('disconnected')
})
it('should update status when connection status changes', async () => {
const { result } = renderHook(() => useChannelStatus(), {
wrapper: createWrapper(true),
})
// Should start connecting then become connected
await waitFor(() => {
expect(result.current).toBe('connected')
}, { timeout: 5000 })
})
it('should disconnect on unmount', async () => {
const { result, unmount } = renderHook(() => useChannelContext(), {
wrapper: createWrapper(true),
})
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
// Unmount
unmount()
// Connection should be disconnected
expect(connection.status).toBe('disconnected')
})
})
})
describeIntegration('useChannelStatus (integration)', () => {
let connection: ChannelConnection
beforeEach(() => {
connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
})
afterEach(() => {
connection.disconnect()
})
const createWrapper = (autoConnect: boolean) => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={autoConnect}>
{children}
</ChannelProvider>
)
}
}
it('should return current connection status', () => {
const { result } = renderHook(() => useChannelStatus(), {
wrapper: createWrapper(false),
})
expect(result.current).toBe('disconnected')
})
it('should track status through connection lifecycle', async () => {
const { result } = renderHook(() => useChannelStatus(), {
wrapper: createWrapper(true),
})
// Wait for connected
await waitFor(() => {
expect(result.current).toBe('connected')
}, { timeout: 5000 })
// Disconnect manually
act(() => {
connection.disconnect()
})
// Should become disconnected
await waitFor(() => {
expect(result.current).toBe('disconnected')
})
})
})

View File

@@ -0,0 +1,158 @@
/**
* Integration tests for channel hooks
*
* These tests call the REAL backend - no mocks.
* Backend must be running: docker-compose up
*
* Run with: RUN_INTEGRATION_TESTS=true npm run test
*/
import { renderHook, waitFor } from '@testing-library/react'
import { ReactNode } from 'react'
import { ChannelProvider } from '../context'
import { useChannel, useChannelLatest, useRPC } from '../hooks'
import { ChannelConnection } from '../connection'
import { describeIntegration, WS_URL } from '../../testing'
describeIntegration('useChannel (integration)', () => {
let connection: ChannelConnection
beforeEach(() => {
connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
})
afterEach(() => {
connection.disconnect()
})
const createWrapper = () => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={true}>
{children}
</ChannelProvider>
)
}
}
describe('subscription', () => {
it('should subscribe to channel when connection is ready', async () => {
const { result } = renderHook(
() => useChannel<{ room: string }, { text: string }, { text: string }>('chat', { room: 'test' }),
{ wrapper: createWrapper() }
)
// Wait for connection to establish
await waitFor(() => {
// Status should progress from connecting
expect(['connecting', 'connected', 'subscribed']).toContain(result.current.status)
}, { timeout: 5000 })
// Should have expected API
expect(typeof result.current.send).toBe('function')
expect(typeof result.current.clearMessages).toBe('function')
expect(typeof result.current.unsubscribe).toBe('function')
expect(Array.isArray(result.current.messages)).toBe(true)
})
})
})
describeIntegration('useChannelLatest (integration)', () => {
let connection: ChannelConnection
beforeEach(() => {
connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
})
afterEach(() => {
connection.disconnect()
})
const createWrapper = () => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={true}>
{children}
</ChannelProvider>
)
}
}
})
describeIntegration('useRPC (integration)', () => {
let connection: ChannelConnection
beforeEach(() => {
connection = new ChannelConnection({
url: WS_URL,
reconnect: false,
})
})
afterEach(() => {
connection.disconnect()
})
const createWrapper = () => {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<ChannelProvider connection={connection} autoConnect={true}>
{children}
</ChannelProvider>
)
}
}
it('should track connection status', async () => {
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
})
it('should call backend echo function via RPC', async () => {
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
// Call echo function
const response = await result.current.call<{ text: string }, { message: string }>(
'echo',
{ text: 'rpc test' }
)
expect(response).toHaveProperty('message')
expect(response.message).toContain('rpc test')
})
it('should call backend add function via RPC', async () => {
const { result } = renderHook(() => useRPC(), { wrapper: createWrapper() })
// Wait for connection
await waitFor(() => {
expect(result.current.status).toBe('connected')
}, { timeout: 5000 })
// Call add function
const response = await result.current.call<{ a: number; b: number }, { result: number }>(
'add',
{ a: 7, b: 8 }
)
expect(response).toHaveProperty('result', 15)
})
})

View File

@@ -0,0 +1,299 @@
/**
* WebSocket connection manager for djarea/channels
*
* Supports both pub/sub channels AND RPC calls over the same connection.
*/
import type {
ConnectionStatus,
OutgoingMessage,
IncomingPayload,
SubscribeOptions,
} from './types'
type MessageHandler = (payload: IncomingPayload) => void
type StatusHandler = (status: ConnectionStatus) => void
/** RPC request message */
export interface RPCRequest<T = unknown> {
action: 'rpc'
id: string
fn: string
args: T
}
/** RPC response - success */
export interface RPCSuccessResponse<T = unknown> {
id: string
ok: true
data: T
}
/** RPC response - error */
export interface RPCErrorResponse {
id: string
ok: false
error: {
code: string
message: string
details?: Record<string, unknown>
}
}
export type RPCResponse<T = unknown> = RPCSuccessResponse<T> | RPCErrorResponse
/** RPC error thrown on failure */
export class RPCError extends Error {
code: string
details?: Record<string, unknown>
constructor(code: string, message: string, details?: Record<string, unknown>) {
super(message)
this.name = 'RPCError'
this.code = code
this.details = details
}
}
export interface ChannelConnectionOptions {
/** WebSocket URL (default: /ws/) */
url?: string
/** Reconnect on disconnect (default: true) */
reconnect?: boolean
/** Reconnection delay in ms (default: 1000) */
reconnectDelay?: number
/** Maximum reconnection attempts (default: 10) */
maxReconnectAttempts?: number
}
export class ChannelConnection {
private ws: WebSocket | null = null
private url: string
private reconnect: boolean
private reconnectDelay: number
private maxReconnectAttempts: number
private reconnectAttempts = 0
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private messageHandlers: Set<MessageHandler> = new Set()
private statusHandlers: Set<StatusHandler> = new Set()
private _status: ConnectionStatus = 'disconnected'
private pendingMessages: OutgoingMessage[] = []
// RPC state
private rpcIdCounter = 0
private pendingRPCs: Map<string, {
resolve: (data: unknown) => void
reject: (error: RPCError) => void
}> = new Map()
constructor(options: ChannelConnectionOptions = {}) {
// Build WebSocket URL
const baseUrl = options.url || '/ws/'
if (typeof window !== 'undefined') {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
this.url = baseUrl.startsWith('ws')
? baseUrl
: `${protocol}//${window.location.host}${baseUrl}`
} else {
this.url = baseUrl
}
this.reconnect = options.reconnect ?? true
this.reconnectDelay = options.reconnectDelay ?? 1000
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10
}
get status(): ConnectionStatus {
return this._status
}
private setStatus(status: ConnectionStatus) {
this._status = status
this.statusHandlers.forEach(handler => handler(status))
}
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
return
}
this.setStatus('connecting')
try {
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
this.reconnectAttempts = 0
this.setStatus('connected')
// Send any pending messages
this.pendingMessages.forEach(msg => this.send(msg))
this.pendingMessages = []
}
this.ws.onclose = (event) => {
this.setStatus('disconnected')
// Attempt reconnection if enabled
if (this.reconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect()
}
}
this.ws.onerror = () => {
// WebSocket errors don't provide useful details (browser security)
// The onclose handler will fire next and trigger reconnection
console.warn('[ChannelConnection] WebSocket error (will reconnect)')
}
this.ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data)
// Check if this is an RPC response (has 'id' and 'ok' fields)
if ('id' in payload && 'ok' in payload) {
this.handleRPCResponse(payload as RPCResponse)
return
}
// Otherwise, it's a channel message
this.messageHandlers.forEach(handler => handler(payload as IncomingPayload))
} catch (e) {
console.error('[ChannelConnection] Failed to parse message:', e)
}
}
} catch (error) {
console.error('[ChannelConnection] Failed to connect:', error)
this.setStatus('disconnected')
if (this.reconnect) {
this.scheduleReconnect()
}
}
}
disconnect(): void {
this.reconnect = false
this.clearReconnectTimer()
if (this.ws) {
this.ws.close()
this.ws = null
}
this.setStatus('disconnected')
}
send(message: OutgoingMessage): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
} else {
// Queue message to send when connected
this.pendingMessages.push(message)
// Ensure we're trying to connect
if (this._status === 'disconnected') {
this.connect()
}
}
}
onMessage(handler: MessageHandler): () => void {
this.messageHandlers.add(handler)
return () => this.messageHandlers.delete(handler)
}
onStatusChange(handler: StatusHandler): () => void {
this.statusHandlers.add(handler)
return () => this.statusHandlers.delete(handler)
}
private scheduleReconnect(): void {
this.clearReconnectTimer()
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts)
this.reconnectAttempts++
console.log(`[ChannelConnection] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
this.reconnectTimer = setTimeout(() => {
this.connect()
}, delay)
}
private clearReconnectTimer(): void {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
// =========================================================================
// RPC Methods
// =========================================================================
/**
* Call a server function via RPC.
*
* @param fn - Function name (as registered on backend)
* @param args - Function arguments
* @returns Promise resolving to function output
* @throws RPCError on failure
*/
rpc<TInput, TOutput>(fn: string, args: TInput): Promise<TOutput> {
return new Promise((resolve, reject) => {
const id = `rpc_${++this.rpcIdCounter}_${Date.now()}`
// Store pending RPC
this.pendingRPCs.set(id, {
resolve: resolve as (data: unknown) => void,
reject,
})
// Send RPC request
const request: RPCRequest<TInput> = {
action: 'rpc',
id,
fn,
args,
}
this.send(request as unknown as OutgoingMessage)
})
}
private handleRPCResponse(response: RPCResponse): void {
const pending = this.pendingRPCs.get(response.id)
if (!pending) {
console.warn(`[ChannelConnection] Received RPC response for unknown id: ${response.id}`)
return
}
this.pendingRPCs.delete(response.id)
if (response.ok) {
pending.resolve(response.data)
} else {
pending.reject(new RPCError(
response.error.code,
response.error.message,
response.error.details,
))
}
}
}
// Singleton connection instance
let defaultConnection: ChannelConnection | null = null
export function getDefaultConnection(options?: ChannelConnectionOptions): ChannelConnection {
if (!defaultConnection) {
defaultConnection = new ChannelConnection(options)
}
return defaultConnection
}

View File

@@ -0,0 +1,102 @@
'use client'
/**
* React context for djarea/channels
*/
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ChannelConnection, type ChannelConnectionOptions } from './connection'
import type { ConnectionStatus } from './types'
interface ChannelContextValue {
connection: ChannelConnection
status: ConnectionStatus
}
const ChannelContext = createContext<ChannelContextValue | null>(null)
export interface ChannelProviderProps {
children: ReactNode
/** WebSocket URL (default: /ws/) */
url?: string
/** Reconnect on disconnect (default: true) */
reconnect?: boolean
/** Reconnection delay in ms (default: 1000) */
reconnectDelay?: number
/** Maximum reconnection attempts (default: 10) */
maxReconnectAttempts?: number
/** Connect automatically on mount (default: true) */
autoConnect?: boolean
/** Custom connection instance (for testing) */
connection?: ChannelConnection
}
export function ChannelProvider({
children,
url,
reconnect,
reconnectDelay,
maxReconnectAttempts,
autoConnect = true,
connection: providedConnection,
}: ChannelProviderProps) {
const connectionRef = useRef<ChannelConnection | null>(null)
// Use provided connection or create one
if (!connectionRef.current) {
connectionRef.current = providedConnection ?? new ChannelConnection({
url,
reconnect,
reconnectDelay,
maxReconnectAttempts,
})
}
const connection = connectionRef.current
// Track status for context value
const [status, setStatus] = useState<ConnectionStatus>(connection.status)
useEffect(() => {
const unsubscribe = connection.onStatusChange(setStatus)
if (autoConnect) {
connection.connect()
}
return () => {
unsubscribe()
connection.disconnect()
}
}, [connection, autoConnect])
const value = useMemo(() => ({
connection,
status,
}), [connection, status])
return (
<ChannelContext value={value}>
{children}
</ChannelContext>
)
}
export function useChannelContext(): ChannelContextValue {
const context = useContext(ChannelContext)
if (!context) {
throw new Error('useChannelContext must be used within a ChannelProvider')
}
return context
}
export function useChannelStatus(): ConnectionStatus {
const { status } = useChannelContext()
return status
}

256
react/src/channels/hooks.ts Normal file
View File

@@ -0,0 +1,256 @@
'use client'
/**
* React hooks for djarea/channels
*
* Includes pub/sub channel hooks AND RPC hooks.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useChannelContext } from './context'
import { RPCError } from './connection'
import type {
ConnectionStatus,
ChannelSubscription,
IncomingPayload,
} from './types'
export interface UseChannelOptions<TDjangoMessage> {
/** Called when subscribed successfully */
onSubscribed?: () => void
/** Called when a message is received */
onMessage?: (message: TDjangoMessage) => void
/** Called on error */
onError?: (error: string) => void
/** Called when unsubscribed */
onUnsubscribed?: () => void
/** Maximum messages to keep in history (default: 100) */
maxMessages?: number
}
/**
* Subscribe to a channel and receive typed messages.
*
* @param channelName - The registered channel name
* @param params - Channel parameters (if required)
* @param options - Subscription options
*/
export function useChannel<
TParams = undefined,
TDjangoMessage = unknown,
TReactMessage = unknown,
>(
channelName: string,
params?: TParams,
options: UseChannelOptions<TDjangoMessage> = {},
): ChannelSubscription<TParams, TDjangoMessage, TReactMessage> {
const { connection, status: connectionStatus } = useChannelContext()
const [messages, setMessages] = useState<TDjangoMessage[]>([])
const [subscribed, setSubscribed] = useState(false)
const optionsRef = useRef(options)
optionsRef.current = options
const maxMessages = options.maxMessages ?? 100
// Stable params reference for effect dependencies
const paramsJson = JSON.stringify(params ?? {})
const paramsRef = useRef(params)
paramsRef.current = params
// Subscribe on mount / when params change
useEffect(() => {
if (connectionStatus !== 'connected') {
return
}
const currentParams = paramsRef.current ?? {}
// Subscribe
connection.send({
action: 'subscribe',
channel: channelName,
params: currentParams as Record<string, unknown>,
})
// Handle incoming messages
const unsubscribeMessages = connection.onMessage((payload: IncomingPayload) => {
// Check for subscription confirmation
if ('subscribed' in payload && payload.channel === channelName) {
setSubscribed(true)
optionsRef.current.onSubscribed?.()
return
}
// Check for unsubscription confirmation
if ('unsubscribed' in payload && payload.channel === channelName) {
setSubscribed(false)
optionsRef.current.onUnsubscribed?.()
return
}
// Check for errors
if ('error' in payload) {
if (!payload.channel || payload.channel === channelName) {
optionsRef.current.onError?.(payload.error)
}
return
}
// Handle data messages
if ('type' in payload && 'data' in payload) {
const message = payload.data as TDjangoMessage
setMessages(prev => {
const next = [...prev, message]
// Trim to max messages
if (next.length > maxMessages) {
return next.slice(-maxMessages)
}
return next
})
optionsRef.current.onMessage?.(message)
}
})
// Cleanup: unsubscribe
return () => {
unsubscribeMessages()
connection.send({
action: 'unsubscribe',
channel: channelName,
params: currentParams as Record<string, unknown>,
})
}
}, [connection, connectionStatus, channelName, paramsJson, maxMessages])
// Send function
const send = useCallback((message: TReactMessage) => {
if (!subscribed) {
console.warn(`[useChannel] Cannot send: not subscribed to ${channelName}`)
return
}
connection.send({
action: 'message',
channel: channelName,
params: (paramsRef.current ?? {}) as Record<string, unknown>,
data: message,
})
}, [connection, channelName, subscribed])
// Unsubscribe function
const unsubscribe = useCallback(() => {
connection.send({
action: 'unsubscribe',
channel: channelName,
params: (paramsRef.current ?? {}) as Record<string, unknown>,
})
}, [connection, channelName])
// Clear messages
const clearMessages = useCallback(() => {
setMessages([])
}, [])
// Derive status
const status: ConnectionStatus = !subscribed
? connectionStatus === 'connected' ? 'connecting' : connectionStatus
: 'connected'
return {
status,
messages,
send: send as ChannelSubscription<TParams, TDjangoMessage, TReactMessage>['send'],
unsubscribe,
clearMessages,
}
}
/**
* Get only the latest message from a channel (useful for presence, typing indicators)
*/
export function useChannelLatest<
TParams = undefined,
TDjangoMessage = unknown,
TReactMessage = unknown,
>(
channelName: string,
params?: TParams,
options: UseChannelOptions<TDjangoMessage> = {},
): Omit<ChannelSubscription<TParams, TDjangoMessage, TReactMessage>, 'messages'> & { latest: TDjangoMessage | null } {
const [latest, setLatest] = useState<TDjangoMessage | null>(null)
const channel = useChannel<TParams, TDjangoMessage, TReactMessage>(
channelName,
params,
{
...options,
onMessage: (msg) => {
setLatest(msg)
options.onMessage?.(msg)
},
maxMessages: 1,
},
)
// Explicitly exclude messages to match the documented API
const { messages: _, ...rest } = channel
return {
...rest,
latest,
}
}
// =============================================================================
// RPC Hooks
// =============================================================================
export interface RPCClient {
/**
* Call a server function.
*
* @param fn - Function name
* @param args - Function arguments
* @returns Promise resolving to function output
* @throws RPCError on failure
*/
call<TInput, TOutput>(fn: string, args: TInput): Promise<TOutput>
/** Connection status */
status: ConnectionStatus
}
/**
* Get an RPC client for calling server functions.
*
* Usage:
* const rpc = useRPC()
* const result = await rpc.call('update_profile', { name: 'New Name' })
*
* The generated code wraps this with typed functions:
* const { updateProfile } = useDjango()
* const result = await updateProfile({ name: 'New Name' })
*/
export function useRPC(): RPCClient {
const { connection, status } = useChannelContext()
const call = useCallback(<TInput, TOutput>(fn: string, args: TInput): Promise<TOutput> => {
return connection.rpc<TInput, TOutput>(fn, args)
}, [connection])
return {
call,
status,
}
}
// Re-export RPCError for convenience
export { RPCError }

View File

@@ -0,0 +1,76 @@
/**
* djarea/channels
*
* Real-time WebSocket communication with Django Channels.
* Type-safe bidirectional messaging.
*
* ## Setup
*
* ```tsx
* // layout.tsx
* import { ChannelProvider } from 'djarea/channels'
*
* export default function Layout({ children }) {
* return (
* <ChannelProvider>
* {children}
* </ChannelProvider>
* )
* }
* ```
*
* ## Usage
*
* ```tsx
* // Using generated hooks (recommended)
* import { useChatChannel } from '@/api/generated.channels'
*
* function Chat({ room }) {
* const chat = useChatChannel({ room })
*
* chat.status // 'connecting' | 'connected' | 'disconnected'
* chat.messages // DjangoMessage[]
* chat.send({ text: 'Hello' }) // Send ReactMessage
* }
* ```
*
* ```tsx
* // Using raw hook (for custom channels)
* import { useChannel } from 'djarea/channels'
*
* function CustomChannel() {
* const channel = useChannel<
* { room: string }, // Params
* { user: string; text: string }, // DjangoMessage
* { text: string } // ReactMessage
* >('chat', { room: 'general' })
*
* // ...
* }
* ```
*/
// Context
export { ChannelProvider, useChannelContext, useChannelStatus } from './context'
export type { ChannelProviderProps } from './context'
// Hooks
export { useChannel, useChannelLatest, useRPC, RPCError } from './hooks'
export type { UseChannelOptions, RPCClient } from './hooks'
// Connection (for advanced use)
export { ChannelConnection, getDefaultConnection } from './connection'
export type {
ChannelConnectionOptions,
RPCRequest,
RPCResponse,
RPCSuccessResponse,
RPCErrorResponse,
} from './connection'
// Types
export type {
ConnectionStatus,
ChannelSubscription,
SubscribeOptions,
} from './types'

View File

@@ -0,0 +1,84 @@
/**
* Types for djarea/channels
*/
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'
export interface ChannelSubscription<TParams = unknown, TDjangoMessage = unknown, TReactMessage = unknown> {
/** Current connection status */
status: ConnectionStatus
/** Received messages */
messages: TDjangoMessage[]
/** Send a message (if channel accepts ReactMessage) */
send: TReactMessage extends never ? never : (message: TReactMessage) => void
/** Unsubscribe from the channel */
unsubscribe: () => void
/** Clear accumulated messages */
clearMessages: () => void
}
export interface SubscribeOptions {
/** Called when subscribed successfully */
onSubscribed?: () => void
/** Called when a message is received */
onMessage?: (message: unknown) => void
/** Called on error */
onError?: (error: string) => void
/** Called when unsubscribed */
onUnsubscribed?: () => void
}
/**
* Protocol messages sent over the WebSocket
*/
export interface SubscribeAction {
action: 'subscribe'
channel: string
params: Record<string, unknown>
}
export interface UnsubscribeAction {
action: 'unsubscribe'
channel: string
params: Record<string, unknown>
}
export interface MessageAction {
action: 'message'
channel: string
params: Record<string, unknown>
data: unknown
}
export type OutgoingMessage = SubscribeAction | UnsubscribeAction | MessageAction
export interface IncomingSubscribed {
subscribed: true
channel: string
params: Record<string, unknown>
}
export interface IncomingUnsubscribed {
unsubscribed: true
channel: string
params: Record<string, unknown>
}
export interface IncomingMessage {
type: string
data: unknown
}
export interface IncomingError {
error: string
channel?: string
}
export type IncomingPayload = IncomingSubscribed | IncomingUnsubscribed | IncomingMessage | IncomingError

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
}

585
react/src/client/index.ts Normal file
View File

@@ -0,0 +1,585 @@
/**
* djarea/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 'djarea/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 'djarea/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 'djarea/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 DjareaProvider 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/djarea/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('[djarea] 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/djarea')
* @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 djarea/jwt.
*
* Usage:
* ```tsx
* // In layout.tsx
* import { NextAuthContext } from 'djarea/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>
)
}

63
react/src/client/react.ts Normal file
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 djarea/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 djarea/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 djarea/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
}

66
react/src/client/types.ts Normal file
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
/** Djarea server function endpoint (default: /api/djarea/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>
}

629
react/src/context.tsx Normal file
View File

@@ -0,0 +1,629 @@
'use client'
/**
* Djarea React Context
*
* Provides server function calls via HTTP (default) or WebSocket RPC (opt-in).
* This is the core React integration for Django server functions.
*
* Transport Model:
* - HTTP-first: Functions use HTTP by default (transport='http' or undefined)
* - WebSocket opt-in: Functions with transport='websocket' use WebSocket RPC
* when connected, falling back to HTTP when disconnected
*
* Two layers:
* 1. DjareaProvider (this file) - Generic provider with name-based API
* - Libraries like Allauth use this: useDjarea(), useContext('current_user')
*
* 2. Generated DjangoContext (in @/api) - Typed wrapper around DjareaProvider
* - Product code uses this: useCurrentUser(), useUpdateProfile()
*
* The generated code wraps DjareaProvider and adds type-safe hooks.
*/
import {
createContext,
useContext as useReactContext,
useEffect,
useMemo,
useRef,
useState,
useCallback,
type ReactNode,
} from 'react'
import { ChannelConnection, RPCError } from 'djarea/channels'
import {
createDjangoCSRClient,
Auth,
type FunctionResponse,
} from 'djarea/client'
import { useJWT } from './jwt'
import { DjangoError, type ErrorCode, type FunctionErrorResponse } from './errors'
// ============================================================================
// Utilities
// ============================================================================
function getCSRFToken(): string | null {
if (typeof document === 'undefined') return null
const match = document.cookie.match(/csrftoken=([^;]+)/)
return match?.[1] ?? null
}
// ============================================================================
// Types
// ============================================================================
export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected'
/** Push message received from server */
export interface PushMessage<T = unknown> {
topic: string
data: T
}
/** Listener for push messages */
export type PushListener<T = unknown> = (message: PushMessage<T>) => void
/** Context data store - maps context names to their data */
export type ContextStore = Record<string, unknown>
/** Hydration data for SSR - maps context names to their initial data */
export type DjareaHydration = Record<string, unknown>
/** Transport mode for server function calls */
export type Transport = 'http' | 'websocket'
export interface DjareaContextValue {
/**
* Call a server function by name.
*
* Transport behavior:
* - 'http' (default): Always use HTTP POST /api/djarea/call/
* - 'websocket': Use WebSocket RPC when connected, HTTP fallback when not
*
* @param functionName - The server function name (e.g., 'echo', 'update_profile')
* @param input - Optional input data for the function
* @param transport - Transport mode ('http' or 'websocket', defaults to 'http')
*/
call: <TInput = unknown, TOutput = unknown>(
functionName: string,
input?: TInput,
transport?: Transport
) => Promise<TOutput>
/**
* Get cached context data by name.
* Returns undefined if the context hasn't been loaded yet.
*/
getContext: <T = unknown>(name: string) => T | undefined
/**
* Refresh a specific context by name.
* Fetches fresh data from the server and updates the cache.
*/
refreshContext: (name: string) => Promise<void>
/**
* Refresh all registered contexts.
*/
refreshAllContexts: () => Promise<void>
/**
* Current WebSocket connection status.
*/
status: ConnectionStatus
/**
* Whether WebSocket RPC is available.
*/
isRPCAvailable: boolean
/**
* Subscribe to push messages for a topic.
* Returns an unsubscribe function.
*/
onPush: <T = unknown>(topic: string, listener: PushListener<T>) => () => void
/**
* Subscribe to context changes.
* Returns an unsubscribe function.
*/
onContextChange: (name: string, listener: (data: unknown) => void) => () => void
/**
* Promise that resolves when the session is initialized (CSRF cookie set).
* Await this before making HTTP calls in contexts where timing matters
* (e.g., calling a server function immediately on mount).
*/
whenReady: Promise<void>
}
export interface DjareaProviderProps {
children: ReactNode
/**
* Initial hydration data for contexts (from SSR).
* Keys are context names, values are the data.
*/
hydration?: DjareaHydration
/**
* List of context names to auto-fetch if not in hydration.
* These will be fetched on mount.
*/
contexts?: string[]
/**
* Base URL for HTTP fallback calls.
* @default '/api/djarea'
*/
baseUrl?: string
/**
* WebSocket URL for RPC calls.
* @default '/ws/'
*/
wsUrl?: string
/**
* Whether to connect WebSocket automatically.
* @default true
*/
autoConnect?: boolean
/**
* WebSocket reconnection options.
*/
reconnect?: boolean
reconnectDelay?: number
maxReconnectAttempts?: number
/**
* Custom connection instance (for testing).
*/
connection?: ChannelConnection
}
// ============================================================================
// Context
// ============================================================================
const DjareaContextInternal = createContext<DjareaContextValue | null>(null)
// ============================================================================
// Provider
// ============================================================================
export function DjareaProvider({
children,
hydration,
contexts: contextNames = [],
baseUrl = '/api/djarea',
wsUrl = '/ws/',
autoConnect = true,
reconnect = true,
reconnectDelay = 1000,
maxReconnectAttempts = 10,
connection: providedConnection,
}: DjareaProviderProps) {
const connectionRef = useRef<ChannelConnection | null>(null)
// Push listeners: Map<topic, Set<listener>>
const pushListenersRef = useRef<Map<string, Set<PushListener>>>(new Map())
// Context change listeners: Map<name, Set<listener>>
const contextListenersRef = useRef<Map<string, Set<(data: unknown) => void>>>(new Map())
// Context data store
const [contextStore, setContextStore] = useState<ContextStore>(() => {
// Initialize from hydration if provided
return hydration ?? {}
})
// Check if JWT is available - use JWT auth if so, otherwise session auth
const jwt = useJWT()
const hasJWT = jwt !== null && jwt.tokens !== null
const [sessionReady, setSessionReady] = useState(false)
// Promise that resolves when session is initialized.
// Exposed via context so any code that needs to wait for CSRF can await it.
const sessionRef = useRef<{ promise: Promise<void>; resolve: () => void } | null>(null)
if (!sessionRef.current) {
let resolve!: () => void
const promise = new Promise<void>(r => { resolve = r })
sessionRef.current = { promise, resolve }
}
// Create HTTP client with appropriate auth method
const httpClient = useMemo(() => {
if (jwt?.getAccessToken) {
return createDjangoCSRClient(Auth.JWT, {
baseUrl,
getAccessToken: jwt.getAccessToken,
})
}
return createDjangoCSRClient(Auth.SESSION, { baseUrl })
}, [hasJWT, jwt?.getAccessToken, baseUrl])
// Create or use provided connection
if (!connectionRef.current) {
connectionRef.current = providedConnection ?? new ChannelConnection({
url: wsUrl,
reconnect,
reconnectDelay,
maxReconnectAttempts,
})
}
const connection = connectionRef.current
// Track connection status
const [status, setStatus] = useState<ConnectionStatus>(
connection.status as ConnectionStatus
)
// The core call function: HTTP-first, WebSocket opt-in
const call = useCallback(
async <TInput = unknown, TOutput = unknown>(
functionName: string,
input?: TInput,
transport: Transport = 'http'
): Promise<TOutput> => {
// Only attempt WebSocket if explicitly requested AND connected
if (transport === 'websocket' && connection.status === 'connected') {
try {
return await connection.rpc<TInput, TOutput>(functionName, input as TInput)
} catch (e) {
// If it's an RPC error (function error), re-throw as DjangoError
if (e instanceof RPCError) {
throw new DjangoError({
error: true,
code: e.code as ErrorCode,
message: e.message,
details: e.details,
})
}
// Connection error - fall through to HTTP
console.warn(
`[Djarea] WebSocket RPC failed for '${functionName}', falling back to HTTP:`,
e
)
}
}
// Wait for session init (CSRF cookie) before making HTTP requests
await sessionRef.current!.promise
const response = await httpClient.request(
'POST',
`${baseUrl}/call/`,
{ fn: functionName, args: input }
)
const data: FunctionResponse<TOutput> = await response.json()
if (data.error) {
throw new DjangoError(data as FunctionErrorResponse)
}
return data.data
},
[connection, baseUrl, httpClient]
)
// Get cached context data
const getContext = useCallback(
<T = unknown>(name: string): T | undefined => {
return contextStore[name] as T | undefined
},
[contextStore]
)
// Refresh a specific context
const refreshContext = useCallback(
async (name: string): Promise<void> => {
try {
const data = await call(name, {})
setContextStore(prev => {
const next = { ...prev, [name]: data }
// Notify listeners
const listeners = contextListenersRef.current.get(name)
if (listeners) {
listeners.forEach(listener => {
try {
listener(data)
} catch (e) {
console.error(`[Djarea] Context listener error for '${name}':`, e)
}
})
}
return next
})
} catch (e) {
console.error(`[Djarea] Failed to refresh context '${name}':`, e)
throw e
}
},
[call]
)
// Refresh all registered contexts
const refreshAllContexts = useCallback(
async (): Promise<void> => {
await Promise.all(contextNames.map(name => refreshContext(name)))
},
[contextNames, refreshContext]
)
// Subscribe to context changes
const onContextChange = useCallback(
(name: string, listener: (data: unknown) => void): (() => void) => {
const listeners = contextListenersRef.current.get(name) ?? new Set()
listeners.add(listener)
contextListenersRef.current.set(name, listeners)
return () => {
const nameListeners = contextListenersRef.current.get(name)
if (nameListeners) {
nameListeners.delete(listener)
if (nameListeners.size === 0) {
contextListenersRef.current.delete(name)
}
}
}
},
[]
)
// Subscribe to push messages
const onPush = useCallback(
<T = unknown>(topic: string, listener: PushListener<T>): (() => void) => {
const listeners = pushListenersRef.current.get(topic) ?? new Set()
listeners.add(listener as PushListener)
pushListenersRef.current.set(topic, listeners)
return () => {
const topicListeners = pushListenersRef.current.get(topic)
if (topicListeners) {
topicListeners.delete(listener as PushListener)
if (topicListeners.size === 0) {
pushListenersRef.current.delete(topic)
}
}
}
},
[]
)
// Connect on mount and listen for push messages
useEffect(() => {
const unsubscribeStatus = connection.onStatusChange((newStatus) => {
setStatus(newStatus as ConnectionStatus)
})
// Listen for all messages (including push)
const unsubscribeMessages = connection.onMessage((payload) => {
if (payload && typeof payload === 'object' && 'type' in payload && payload.type === 'push') {
const topic = (payload as { topic?: string }).topic
const data = (payload as { data?: unknown }).data
if (topic) {
const listeners = pushListenersRef.current.get(topic)
if (listeners) {
const message: PushMessage = { topic, data }
listeners.forEach(listener => {
try {
listener(message)
} catch (e) {
console.error('[Djarea] Push listener error:', e)
}
})
}
}
}
})
if (autoConnect) {
connection.connect()
}
return () => {
unsubscribeStatus()
unsubscribeMessages()
}
}, [connection, autoConnect])
// Session init for CSR (fallback if proxy didn't run)
useEffect(() => {
if (hasJWT || getCSRFToken()) {
setSessionReady(true)
sessionRef.current?.resolve()
return
}
fetch(`${baseUrl}/session/`, { credentials: 'include' })
.catch(e => console.error('[DjareaProvider] Session init failed:', e))
.finally(() => {
setSessionReady(true)
sessionRef.current?.resolve()
})
}, [hasJWT, baseUrl])
// Auto-fetch contexts that weren't hydrated
useEffect(() => {
if (!sessionReady) return
if (!hydration) {
refreshAllContexts()
} else {
const missing = contextNames.filter(name => !(name in hydration))
if (missing.length > 0) {
Promise.all(missing.map(name => refreshContext(name)))
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionReady])
const isRPCAvailable = status === 'connected'
const value = useMemo<DjareaContextValue>(
() => ({
call,
getContext,
refreshContext,
refreshAllContexts,
status,
isRPCAvailable,
onPush,
onContextChange,
whenReady: sessionRef.current!.promise,
}),
[call, getContext, refreshContext, refreshAllContexts, status, isRPCAvailable, onPush, onContextChange]
)
return (
<DjareaContextInternal value={value}>
{children}
</DjareaContextInternal>
)
}
// ============================================================================
// Hooks
// ============================================================================
/**
* Access the Djarea context.
*
* Provides generic name-based API for server functions and contexts.
* Libraries should use this hook, not the typed generated hooks.
*
* @example
* ```tsx
* // Library code (e.g., Allauth)
* import { useDjarea } from 'djarea'
*
* function useUser() {
* const { getContext } = useDjarea()
* return getContext('current_user')
* }
* ```
*/
export function useDjarea(): DjareaContextValue {
const context = useReactContext(DjareaContextInternal)
if (!context) {
throw new Error('useDjarea must be used within a DjareaProvider')
}
return context
}
/**
* Get cached context data by name.
*
* For use by libraries that need to access context data without knowing types.
*
* @example
* ```tsx
* // In Allauth library
* function useUser() {
* return useDjareaContext('current_user')
* }
* ```
*/
export function useDjareaContext<T = unknown>(name: string): T | undefined {
const { getContext } = useDjarea()
return getContext<T>(name)
}
/**
* Get a function caller by name with transport control.
*
* For use by libraries that need to call functions without knowing types.
* The transport parameter is baked into the returned function.
*
* @param functionName - The server function name
* @param transport - Transport mode ('http' or 'websocket', defaults to 'http')
*
* @example
* ```tsx
* // HTTP-only function (default)
* function useUpdateProfile() {
* return useDjareaCall('update_profile')
* }
*
* // WebSocket-enabled function
* function useSendMessage() {
* return useDjareaCall('send_message', 'websocket')
* }
* ```
*/
export function useDjareaCall<TInput = unknown, TOutput = unknown>(
functionName: string,
transport: Transport = 'http'
): (input?: TInput) => Promise<TOutput> {
const { call } = useDjarea()
return useCallback(
(input?: TInput) => call<TInput, TOutput>(functionName, input, transport),
[call, functionName, transport]
)
}
/**
* Get the current WebSocket connection status.
*/
export function useDjareaStatus(): ConnectionStatus {
const { status } = useDjarea()
return status
}
/**
* Subscribe to push messages for a topic.
* Automatically unsubscribes when the component unmounts.
*/
export function usePush<T = unknown>(
topic: string,
callback: PushListener<T>
): void {
const { onPush } = useDjarea()
const callbackRef = useRef(callback)
useEffect(() => {
callbackRef.current = callback
}, [callback])
useEffect(() => {
const listener: PushListener<T> = (message) => {
callbackRef.current(message)
}
return onPush(topic, listener)
}, [topic, onPush])
}
// ============================================================================
// Legacy Aliases (for backwards compatibility during migration)
// ============================================================================
/** @deprecated Use DjareaProvider instead */
export const DjangoContext = DjareaProvider
/** @deprecated Use useDjarea instead */
export const useDjango = useDjarea
/** @deprecated Use useDjareaStatus instead */
export const useDjangoStatus = useDjareaStatus
/** @deprecated Use useDjareaCall instead */
export function useServerFunction<TInput = unknown, TOutput = unknown>(
functionName: string
): (input: TInput) => Promise<TOutput> {
const { call } = useDjarea()
return useCallback(
(input: TInput) => call<TInput, TOutput>(functionName, input),
[call, functionName]
)
}
// Re-export types for the legacy API
export type DjangoContextValue = DjareaContextValue
export type DjangoContextProps = DjareaProviderProps

107
react/src/errors.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Django Server Error Types
*
* Typed errors for server function failures.
*/
/**
* Error codes returned by the server
*/
export type ErrorCode =
| 'NOT_FOUND'
| 'VALIDATION_ERROR'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'BAD_REQUEST'
| 'INTERNAL_ERROR'
| 'NOT_IMPLEMENTED'
/**
* Error response structure from the server
*/
export interface FunctionErrorResponse {
error: true
code: ErrorCode
message: string
details?: {
fields?: Record<string, string[]>
required?: string[]
type?: string
[key: string]: unknown
}
}
/**
* Error thrown when a server function call fails
*/
export class DjangoError extends Error {
/**
* Error code from the server
*/
readonly code: ErrorCode
/**
* Additional error details
*/
readonly details?: FunctionErrorResponse['details']
/**
* The original error response
*/
readonly response: FunctionErrorResponse
constructor(response: FunctionErrorResponse) {
super(response.message)
this.name = 'DjangoError'
this.code = response.code
this.details = response.details
this.response = response
// Maintains proper stack trace for where error was thrown
if (Error.captureStackTrace) {
Error.captureStackTrace(this, DjangoError)
}
}
/**
* Check if this is a validation error
*/
isValidationError(): boolean {
return this.code === 'VALIDATION_ERROR'
}
/**
* Check if this is an authentication error
*/
isAuthError(): boolean {
return this.code === 'UNAUTHORIZED' || this.code === 'FORBIDDEN'
}
/**
* Check if this is a not found error
*/
isNotFound(): boolean {
return this.code === 'NOT_FOUND'
}
/**
* Get field-level validation errors (if this is a validation error)
*/
getFieldErrors(): Record<string, string[]> | null {
if (this.code === 'VALIDATION_ERROR' && this.details?.fields) {
return this.details.fields
}
return null
}
/**
* Get error for a specific field
*/
getFieldError(field: string): string | null {
const errors = this.getFieldErrors()
if (errors && errors[field]?.length > 0) {
return errors[field][0]
}
return null
}
}

1163
react/src/forms.ts Normal file

File diff suppressed because it is too large Load Diff

283
react/src/generator/cli.mjs Executable file
View File

@@ -0,0 +1,283 @@
#!/usr/bin/env node
/**
* Djarea Code Generator CLI
*
* Generate TypeScript types, React provider, and hooks from Django schemas.
*
* Usage:
* npx djarea-generate # Run once
* npx djarea-generate --watch # Watch mode
*/
import { promises as fs } from 'fs'
import path from 'path'
import { fetchChannelsSchema, fetchDjareaSchema } from './lib/fetch.mjs'
import { generateDjareaFiles } from './lib/djarea.mjs'
import { generateChannelsFiles } from './lib/channels.mjs'
import { generateIndex } from './lib/index.mjs'
// Use cwd — the script runs via `npx djarea-generate` from the frontend root
const frontendDir = process.cwd()
/**
* Load configuration from django.config.mjs
*/
async function loadConfig(configPath) {
const fullPath = path.resolve(frontendDir, configPath)
try {
await fs.access(fullPath)
} catch {
throw new Error(`Config file not found: ${fullPath}`)
}
// Convert to file:// URL for Windows compatibility
const fileUrl = new URL(`file://${fullPath.replace(/\\/g, '/')}`)
if (configPath.endsWith('.mjs') || configPath.endsWith('.js')) {
const module = await import(fileUrl)
return module.default
}
if (configPath.endsWith('.ts')) {
try {
const module = await import(fileUrl)
return module.default
} catch {
throw new Error(
`Cannot load TypeScript config directly. Either:\n` +
` 1. Install tsx: npm install -D tsx\n` +
` 2. Use django.config.mjs instead`
)
}
}
throw new Error(`Unsupported config file format: ${configPath}`)
}
/**
* Write generated code to file.
*/
async function writeOutput(filePath, content) {
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, content, 'utf8')
}
/**
* Run schema generation.
*/
async function generate(config, options = {}) {
const { output } = options
console.log('[djarea] Starting schema generation...')
const outputPath = output || config.output || 'src/api/generated.ts'
let channelsSchema = null
let djareaSchema = null
// Fetch and generate channels if available
try {
console.log('[djarea] Fetching channels schema...')
channelsSchema = await fetchChannelsSchema(config.source, frontendDir)
const channelCount = channelsSchema['x-djarea-channels']?.length || 0
if (channelCount > 0) {
console.log(`[djarea] Found ${channelCount} channels`)
const channelsTypesPath = outputPath.replace(/\.ts$/, '.channels.ts')
const fullChannelsTypesPath = path.resolve(frontendDir, channelsTypesPath)
const channelsHooksPath = outputPath.replace(/\.ts$/, '.channels.hooks.tsx')
const fullChannelsHooksPath = path.resolve(frontendDir, channelsHooksPath)
const channelsSchemaPath = outputPath.replace(/\.ts$/, '.channels.schema.json')
const fullChannelsSchemaPath = path.resolve(frontendDir, channelsSchemaPath)
const { types: channelsTypes, hooks: channelsHooks } = await generateChannelsFiles(channelsSchema)
console.log(`[djarea] Generating -> ${channelsTypesPath}`)
await writeOutput(fullChannelsTypesPath, channelsTypes)
if (channelsHooks) {
console.log(`[djarea] Generating -> ${channelsHooksPath}`)
await writeOutput(fullChannelsHooksPath, channelsHooks)
}
console.log(`[djarea] Generating -> ${channelsSchemaPath}`)
await writeOutput(fullChannelsSchemaPath, JSON.stringify(channelsSchema, null, 2))
} else {
console.log('[djarea] No channels registered, skipping channels generation')
}
} catch (err) {
console.log(`[djarea] Channels schema not available: ${err.message}`)
}
// Fetch and generate djarea files
try {
console.log('[djarea] Fetching djarea schema...')
djareaSchema = await fetchDjareaSchema(config.source, frontendDir)
const functionCount = djareaSchema['x-djarea-functions']?.length || 0
if (functionCount > 0) {
console.log(`[djarea] Found ${functionCount} djarea functions`)
const djareaTypesPath = outputPath.replace(/\.ts$/, '.djarea.ts')
const fullDjareaTypesPath = path.resolve(frontendDir, djareaTypesPath)
const djareaProviderPath = outputPath.replace(/\.ts$/, '.django.tsx')
const fullDjareaProviderPath = path.resolve(frontendDir, djareaProviderPath)
const djareaServerPath = outputPath.replace(/\.ts$/, '.django.server.ts')
const fullDjareaServerPath = path.resolve(frontendDir, djareaServerPath)
const djareaFormsPath = outputPath.replace(/\.ts$/, '.forms.ts')
const fullDjareaFormsPath = path.resolve(frontendDir, djareaFormsPath)
const djareaSchemaPath = outputPath.replace(/\.ts$/, '.djarea.schema.json')
const fullDjareaSchemaPath = path.resolve(frontendDir, djareaSchemaPath)
const hasChannels = (channelsSchema?.['x-djarea-channels']?.length || 0) > 0
const { types: djareaTypes, provider: djareaProvider, server: djareaServer, forms: djareaForms } = await generateDjareaFiles(djareaSchema, { hasChannels })
console.log(`[djarea] Generating -> ${djareaTypesPath}`)
await writeOutput(fullDjareaTypesPath, djareaTypes)
if (djareaProvider) {
console.log(`[djarea] Generating -> ${djareaProviderPath}`)
await writeOutput(fullDjareaProviderPath, djareaProvider)
}
if (djareaServer) {
console.log(`[djarea] Generating -> ${djareaServerPath}`)
await writeOutput(fullDjareaServerPath, djareaServer)
}
if (djareaForms) {
console.log(`[djarea] Generating -> ${djareaFormsPath}`)
await writeOutput(fullDjareaFormsPath, djareaForms)
}
console.log(`[djarea] Generating -> ${djareaSchemaPath}`)
await writeOutput(fullDjareaSchemaPath, JSON.stringify(djareaSchema, null, 2))
} else {
console.log('[djarea] No djarea functions registered, skipping djarea generation')
}
} catch (err) {
console.log(`[djarea] Djarea schema not available: ${err.message}`)
}
// Generate consolidated index.ts
const indexPath = path.dirname(outputPath) + '/index.ts'
const fullIndexPath = path.resolve(frontendDir, indexPath)
console.log(`[djarea] Generating -> ${indexPath}`)
const indexContent = generateIndex({
channelsSchema,
djareaSchema,
})
await writeOutput(fullIndexPath, indexContent)
console.log('[djarea] Generation complete!')
}
/**
* Watch for changes and regenerate.
*/
async function watch(config, options) {
const debounce = config.watch?.debounce || 1000
let timeout = null
let running = false
async function runGenerate() {
if (running) {
timeout = setTimeout(runGenerate, debounce)
return
}
running = true
try {
await generate(config, options)
} catch (err) {
console.error('[djarea] Generation failed:', err.message)
} finally {
running = false
}
}
await runGenerate()
console.log('[djarea] Watching for changes (press Ctrl+C to stop)...')
if (config.source.django) {
const { watch: chokidarWatch } = await import('chokidar')
const djangoDir = path.resolve(frontendDir, path.dirname(config.source.django.managePath))
const watcher = chokidarWatch([
path.join(djangoDir, '**/*.py'),
], {
ignored: [
'**/node_modules/**',
'**/__pycache__/**',
'**/migrations/**',
'**/.venv/**',
],
ignoreInitial: true,
})
watcher.on('change', (filePath) => {
console.log(`[djarea] Detected change: ${path.relative(djangoDir, filePath)}`)
if (timeout) clearTimeout(timeout)
timeout = setTimeout(runGenerate, debounce)
})
}
process.on('SIGINT', () => {
console.log('\n[djarea] Stopping watch mode...')
process.exit(0)
})
}
/**
* Main entry point.
*/
async function main() {
const args = process.argv.slice(2)
let configPath = 'django.config.mjs'
let watchMode = false
let output = null
for (let i = 0; i < args.length; i++) {
if (args[i] === '--config' || args[i] === '-c') {
configPath = args[++i]
} else if (args[i] === '--watch' || args[i] === '-w') {
watchMode = true
} else if (args[i] === '--output' || args[i] === '-o') {
output = args[++i]
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
Djarea Code Generator - Generate TypeScript from Django schemas
Usage:
npx djarea-generate [options]
Options:
-c, --config <path> Config file path (default: django.config.mjs)
-w, --watch Watch mode - regenerate on changes
-o, --output <path> Output file path (overrides config)
-h, --help Show this help message
`)
process.exit(0)
}
}
const config = await loadConfig(configPath)
const options = { output }
if (watchMode) {
await watch(config, options)
} else {
await generate(config, options)
}
}
main().catch(err => {
console.error('[djarea] Error:', err.message)
process.exit(1)
})

View File

@@ -0,0 +1,155 @@
/**
* Channels Code Generator
*
* Generates TypeScript types and React hooks from Channels OpenAPI schema.
* Uses openapi-typescript for robust type generation.
*/
import openapiTS, { astToString } from 'openapi-typescript'
/**
* Generate channels TypeScript types using openapi-typescript.
*/
export async function generateChannelsTypes(schema) {
// Generate types using openapi-typescript
const ast = await openapiTS(schema)
const typesCode = astToString(ast)
const lines = [
'// AUTO-GENERATED by djarea - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// ============================================================================',
'// OpenAPI Types (generated by openapi-typescript)',
'// ============================================================================',
'',
typesCode,
'',
]
// Extract channel metadata from x-djarea-channels extension
const channels = schema['x-djarea-channels'] || []
if (channels.length > 0) {
lines.push('// ============================================================================')
lines.push('// Convenience Type Exports')
lines.push('// ============================================================================')
lines.push('')
for (const channel of channels) {
if (channel.hasParams) {
lines.push(`export type ${channel.paramsType} = components["schemas"]["${channel.paramsType}"]`)
}
if (channel.hasReactMessage) {
lines.push(`export type ${channel.reactMessageType} = components["schemas"]["${channel.reactMessageType}"]`)
}
if (channel.hasDjangoMessage) {
lines.push(`export type ${channel.djangoMessageType} = components["schemas"]["${channel.djangoMessageType}"]`)
}
}
lines.push('')
lines.push('// ============================================================================')
lines.push('// Channel Registry')
lines.push('// ============================================================================')
lines.push('')
lines.push('export const CHANNELS = {')
for (const channel of channels) {
lines.push(` ${channel.name}: {`)
lines.push(` name: '${channel.name}',`)
lines.push(` pascalName: '${channel.pascalName}',`)
lines.push(` hasParams: ${channel.hasParams},`)
lines.push(` hasReactMessage: ${channel.hasReactMessage},`)
lines.push(` hasDjangoMessage: ${channel.hasDjangoMessage},`)
if (channel.hasParams) {
lines.push(` paramsType: '${channel.paramsType}',`)
}
if (channel.hasReactMessage) {
lines.push(` reactMessageType: '${channel.reactMessageType}',`)
}
if (channel.hasDjangoMessage) {
lines.push(` djangoMessageType: '${channel.djangoMessageType}',`)
}
lines.push(` },`)
}
lines.push('} as const')
} else {
lines.push('export const CHANNELS = {} as const')
}
lines.push('')
return lines.join('\n')
}
/**
* Generate channel hooks from metadata.
*/
export function generateChannelsHooks(schema) {
const channels = schema['x-djarea-channels'] || []
if (channels.length === 0) {
return null
}
const lines = [
"'use client'",
'',
'// AUTO-GENERATED by djarea - do not edit manually',
'// Regenerate with: npm run schemas',
'',
"import { useChannel, type ChannelSubscription } from 'djarea/channels'",
'',
]
// Collect type imports
const typeImports = []
for (const channel of channels) {
if (channel.hasParams) typeImports.push(channel.paramsType)
if (channel.hasReactMessage) typeImports.push(channel.reactMessageType)
if (channel.hasDjangoMessage) typeImports.push(channel.djangoMessageType)
}
if (typeImports.length > 0) {
lines.push(`import type { ${typeImports.join(', ')} } from './generated.channels'`)
lines.push('')
}
// Generate hooks for each channel
lines.push('// ============================================================================')
lines.push('// Channel Hooks')
lines.push('// ============================================================================')
lines.push('')
for (const channel of channels) {
const paramsType = channel.hasParams ? channel.paramsType : 'Record<string, never>'
const reactMsgType = channel.hasReactMessage ? channel.reactMessageType : 'never'
const djangoMsgType = channel.hasDjangoMessage ? channel.djangoMessageType : 'never'
lines.push(`/**`)
lines.push(` * Hook for the ${channel.name} channel.`)
lines.push(` */`)
if (channel.hasParams) {
lines.push(`export function use${channel.pascalName}Channel(params: ${paramsType}): ChannelSubscription<${paramsType}, ${djangoMsgType}, ${reactMsgType}> {`)
lines.push(` return useChannel('${channel.name}', params)`)
} else {
lines.push(`export function use${channel.pascalName}Channel(): ChannelSubscription<Record<string, never>, ${djangoMsgType}, ${reactMsgType}> {`)
lines.push(` return useChannel('${channel.name}', {})`)
}
lines.push('}')
lines.push('')
}
return lines.join('\n')
}
/**
* Generate all channels files.
*/
export async function generateChannelsFiles(schema) {
const types = await generateChannelsTypes(schema)
const hooks = generateChannelsHooks(schema)
return { types, hooks }
}

View File

@@ -0,0 +1,770 @@
/**
* Djarea Code Generator
*
* Generates TypeScript types and React provider from Djarea OpenAPI schema.
* Uses openapi-typescript for robust type generation.
*
* Output structure:
* - generated.djarea.ts - Types only (from OpenAPI)
* - generated.provider.tsx - Typed provider wrapping DjareaProvider + hooks
* - generated.forms.ts - Typed form hooks with Zod schemas
*/
import openapiTS, { astToString } from 'openapi-typescript'
// TypeScript SyntaxKind values for AST manipulation
const SyntaxKind = {
InterfaceDeclaration: 265,
TypeAliasDeclaration: 266,
PropertySignature: 172,
TypeReference: 184,
IndexedAccessType: 200,
Identifier: 80,
StringLiteral: 11,
}
/**
* Get identifier name from AST node.
*/
function idName(node) {
return node?.kind === SyntaxKind.Identifier ? node.escapedText : undefined
}
/**
* Extract schema names from openapi-typescript AST.
*/
function getSchemaNamesFromAst(ast) {
if (!Array.isArray(ast)) return []
const componentsNode = ast.find(
node =>
node?.kind === SyntaxKind.InterfaceDeclaration &&
idName(node?.name) === 'components'
)
if (!componentsNode?.members) return []
const schemasProp = componentsNode.members.find(
member =>
member?.kind === SyntaxKind.PropertySignature &&
idName(member?.name) === 'schemas' &&
Array.isArray(member?.type?.members)
)
if (!schemasProp) return []
return schemasProp.type.members
.map(member =>
member?.kind === SyntaxKind.PropertySignature ? idName(member.name) : undefined
)
.filter(n => typeof n === 'string')
}
/**
* Build convenience type exports for schemas.
*/
function buildSchemaExports(schemaNames) {
if (!schemaNames.length) return ''
return schemaNames
.map(name => `export type ${name} = components["schemas"]["${name}"]`)
.join('\n')
}
/**
* Generate the types file using openapi-typescript.
*/
export async function generateDjareaTypes(schema) {
// Generate types using openapi-typescript
const ast = await openapiTS(schema)
const schemaNames = getSchemaNamesFromAst(ast)
const typesCode = astToString(ast)
const lines = [
'// AUTO-GENERATED by djarea - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// ============================================================================',
'// OpenAPI Types (generated by openapi-typescript)',
'// ============================================================================',
'',
typesCode,
'',
'// ============================================================================',
'// Convenience Type Exports',
'// ============================================================================',
'',
buildSchemaExports(schemaNames),
'',
'// ============================================================================',
'// Function Registry (for reference)',
'// ============================================================================',
'',
"export type Transport = 'http' | 'websocket' | 'both'",
'',
]
// Extract function metadata from x-djarea-functions extension
const functions = schema['x-djarea-functions'] || []
if (functions.length > 0) {
lines.push('export const DJANGO_FUNCTIONS = {')
for (const fn of functions) {
lines.push(` ${fn.camelName}: {`)
lines.push(` name: '${fn.name}',`)
lines.push(` hasInput: ${fn.hasInput},`)
lines.push(` isContext: ${fn.isContext},`)
lines.push(` transport: '${fn.transport}' as Transport,`)
lines.push(` },`)
}
lines.push('} as const')
} else {
lines.push('export const DJANGO_FUNCTIONS = {} as const')
}
lines.push('')
return lines.join('\n')
}
/**
* Generate the React provider that wraps DjareaProvider with typed hooks.
*
* The generated provider:
* - Wraps DjareaProvider (from djarea library)
* - Passes context names for auto-fetch
* - Provides typed hooks for contexts and functions
*/
export function generateDjareaProvider(schema, options = {}) {
const { hasChannels = false } = options
const functions = schema['x-djarea-functions'] || []
if (functions.length === 0) {
return null
}
// Separate contexts from regular functions
const contexts = functions.filter(fn => fn.isContext)
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
// Collect type imports
const typeImports = []
for (const fn of functions) {
if (fn.hasInput && fn.inputType) {
typeImports.push(fn.inputType)
}
if (fn.outputType) {
typeImports.push(fn.outputType)
}
}
const uniqueTypeImports = [...new Set(typeImports)].sort()
const lines = [
"'use client'",
'',
'// AUTO-GENERATED by djarea - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// This file provides typed wrappers around the djarea library.',
'// - DjangoContext: Typed provider wrapping DjareaProvider',
'// - Typed hooks: useAuthStatus(), useUser(), etc.',
'',
"import { type ReactNode, useCallback } from 'react'",
"import {",
" DjareaProvider,",
" useDjarea,",
" useDjareaContext,",
" useDjareaCall,",
" type DjareaHydration,",
" type Transport,",
"} from 'djarea'",
...(hasChannels ? [
"import { ChannelProvider, ChannelConnection } from 'djarea/channels'",
"import { useRef } from 'react'",
] : []),
'',
]
if (uniqueTypeImports.length > 0) {
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`)
lines.push('')
}
// ============================================================================
// Hydration types
// ============================================================================
lines.push('// ============================================================================')
lines.push('// Hydration Types')
lines.push('// ============================================================================')
lines.push('')
if (contexts.length > 0) {
lines.push('/** Typed hydration data for SSR */')
lines.push('export interface DjangoHydration {')
for (const ctx of contexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
}
lines.push('}')
lines.push('')
lines.push('/** Convert typed hydration to djarea format */')
lines.push('function toDjareaHydration(hydration?: DjangoHydration): DjareaHydration | undefined {')
lines.push(' if (!hydration) return undefined')
lines.push(' const result: DjareaHydration = {}')
for (const ctx of contexts) {
lines.push(` if (hydration.${ctx.camelName} !== undefined) result['${ctx.name}'] = hydration.${ctx.camelName}`)
}
lines.push(' return result')
lines.push('}')
lines.push('')
}
// ============================================================================
// Provider
// ============================================================================
lines.push('// ============================================================================')
lines.push('// Provider')
lines.push('// ============================================================================')
lines.push('')
lines.push('export interface DjangoContextProps {')
lines.push(' children: ReactNode')
if (contexts.length > 0) {
lines.push(' /** SSR hydration data */')
lines.push(' hydration?: DjangoHydration')
}
lines.push(' /** WebSocket URL for RPC calls (default: /ws/) */')
lines.push(' wsUrl?: string')
lines.push(' /** Base URL for HTTP fallback (default: /api/djarea) */')
lines.push(' baseUrl?: string')
lines.push('}')
lines.push('')
// Context names array for DjareaProvider
const contextNames = contexts.map(ctx => `'${ctx.name}'`).join(', ')
lines.push('/**')
lines.push(' * Typed Django context provider.')
lines.push(' *')
lines.push(' * Wraps DjareaProvider with:')
lines.push(' * - Typed hydration')
lines.push(' * - Auto-fetch for registered contexts')
lines.push(' *')
lines.push(' * Usage:')
lines.push(' * <DjangoContext hydration={hydration}>')
lines.push(' * <App />')
lines.push(' * </DjangoContext>')
lines.push(' */')
lines.push('export function DjangoContext({')
lines.push(' children,')
if (contexts.length > 0) {
lines.push(' hydration,')
}
lines.push(' wsUrl,')
lines.push(' baseUrl,')
lines.push('}: DjangoContextProps) {')
if (hasChannels) {
lines.push(' const connectionRef = useRef<ChannelConnection | null>(null)')
lines.push(' if (!connectionRef.current) {')
lines.push(" connectionRef.current = new ChannelConnection({ url: wsUrl || '/ws/' })")
lines.push(' }')
lines.push('')
}
lines.push(' return (')
lines.push(' <DjareaProvider')
if (contexts.length > 0) {
lines.push(' hydration={toDjareaHydration(hydration)}')
lines.push(` contexts={[${contextNames}]}`)
}
lines.push(' wsUrl={wsUrl}')
lines.push(' baseUrl={baseUrl}')
if (hasChannels) {
lines.push(' connection={connectionRef.current}')
}
lines.push(' >')
if (hasChannels) {
lines.push(' <ChannelProvider connection={connectionRef.current} autoConnect={true}>')
lines.push(' {children}')
lines.push(' </ChannelProvider>')
} else {
lines.push(' {children}')
}
lines.push(' </DjareaProvider>')
lines.push(' )')
lines.push('}')
lines.push('')
// ============================================================================
// Context Hooks
// ============================================================================
if (contexts.length > 0) {
lines.push('// ============================================================================')
lines.push('// Context Hooks (typed wrappers)')
lines.push('// ============================================================================')
lines.push('')
for (const ctx of contexts) {
const pascal = pascalCase(ctx.camelName)
lines.push(`/**`)
lines.push(` * Get ${ctx.name} context data.`)
lines.push(` * @throws if context not loaded yet`)
lines.push(` */`)
lines.push(`export function use${pascal}(): ${ctx.outputType} {`)
lines.push(` const data = useDjareaContext<${ctx.outputType}>('${ctx.name}')`)
lines.push(` if (data === undefined) {`)
lines.push(` throw new Error('use${pascal}: context not loaded yet')`)
lines.push(` }`)
lines.push(` return data`)
lines.push(`}`)
lines.push('')
}
// Refresh hooks
lines.push('/**')
lines.push(' * Get context refresh functions without subscribing to data changes.')
lines.push(' * Use this in components that only need to trigger refreshes.')
lines.push(' */')
lines.push('export function useDjangoRefresh() {')
lines.push(' const { refreshContext, refreshAllContexts } = useDjarea()')
lines.push(' return {')
for (const ctx of contexts) {
const pascal = pascalCase(ctx.camelName)
lines.push(` refresh${pascal}: () => refreshContext('${ctx.name}'),`)
}
lines.push(' refreshAll: refreshAllContexts,')
lines.push(' }')
lines.push('}')
lines.push('')
}
// ============================================================================
// Function Hooks
// ============================================================================
if (regularFunctions.length > 0) {
lines.push('// ============================================================================')
lines.push('// Function Hooks (typed wrappers)')
lines.push('// ============================================================================')
lines.push('')
for (const fn of regularFunctions) {
const pascal = pascalCase(fn.camelName)
// Transport is known at generation time - pass it directly
const transport = fn.transport || 'http'
if (fn.hasInput) {
lines.push(`/**`)
lines.push(` * Call ${fn.name} server function.`)
lines.push(` * Transport: ${transport}`)
lines.push(` */`)
lines.push(`export function use${pascal}() {`)
lines.push(` return useDjareaCall<${fn.inputType}, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(`}`)
} else {
lines.push(`/**`)
lines.push(` * Call ${fn.name} server function.`)
lines.push(` * Transport: ${transport}`)
lines.push(` */`)
lines.push(`export function use${pascal}() {`)
lines.push(` return useDjareaCall<void, ${fn.outputType}>('${fn.name}', '${transport}')`)
lines.push(`}`)
}
lines.push('')
}
}
// ============================================================================
// Re-exports
// ============================================================================
lines.push('// ============================================================================')
lines.push('// Re-exports from djarea library')
lines.push('// ============================================================================')
lines.push('')
lines.push("export { useDjarea, useDjareaStatus, usePush, DjangoError } from 'djarea'")
lines.push("export type { ConnectionStatus, PushMessage, PushListener } from 'djarea'")
lines.push('')
return lines.join('\n')
}
/**
* Generate server-side hydration helper (runs in Next.js server components).
* This is separate from the client file because it needs to run on the server.
*/
export function generateDjareaServer(schema) {
const functions = schema['x-djarea-functions'] || []
const contexts = functions.filter(fn => fn.isContext)
if (contexts.length === 0) {
return null
}
// Collect type imports for contexts
const typeImports = contexts.map(ctx => ctx.outputType).filter(Boolean)
const uniqueTypeImports = [...new Set(typeImports)].sort()
const lines = [
'// AUTO-GENERATED by djarea - do not edit manually',
'// Regenerate with: npm run schemas',
'//',
'// Server-side functions for SSR hydration.',
'// These run in Next.js server components/layouts.',
'',
]
if (uniqueTypeImports.length > 0) {
lines.push(`import type { ${uniqueTypeImports.join(', ')} } from './generated.djarea'`)
lines.push('')
}
// Hydration type
lines.push('// ============================================================================')
lines.push('// Hydration Types')
lines.push('// ============================================================================')
lines.push('')
lines.push('/** Typed hydration data for SSR */')
lines.push('export interface DjangoHydration {')
for (const ctx of contexts) {
lines.push(` ${ctx.camelName}?: ${ctx.outputType}`)
}
lines.push('}')
lines.push('')
// SSR Hydration Helper
lines.push('// ============================================================================')
lines.push('// SSR Hydration Helper')
lines.push('// ============================================================================')
lines.push('')
lines.push('/**')
lines.push(' * Fetch hydration data for SSR.')
lines.push(' *')
lines.push(' * Call this in your server component:')
lines.push(' * const hydration = await getDjangoHydration(client)')
lines.push(' * return <DjangoContext hydration={hydration}>...</DjangoContext>')
lines.push(' */')
lines.push('export async function getDjangoHydration(')
lines.push(" client: { request: (method: string, url: string, body?: unknown) => Promise<Response> }")
lines.push('): Promise<DjangoHydration> {')
lines.push(' const hydration: DjangoHydration = {}')
lines.push('')
lines.push(' const results = await Promise.allSettled([')
for (const ctx of contexts) {
lines.push(` client.request('POST', '/api/djarea/call/', { fn: '${ctx.name}', args: {} }),`)
}
lines.push(' ])')
lines.push('')
contexts.forEach((ctx, i) => {
lines.push(` if (results[${i}].status === 'fulfilled') {`)
lines.push(` const data = await (results[${i}] as PromiseFulfilledResult<Response>).value.json()`)
lines.push(` if (data.error) {`)
lines.push(` console.error('[getDjangoHydration] ${ctx.name} failed:', data.code, data.message)`)
lines.push(` } else {`)
lines.push(` hydration.${ctx.camelName} = data.data`)
lines.push(` }`)
lines.push(` } else {`)
lines.push(` console.error('[getDjangoHydration] ${ctx.name} request failed:', (results[${i}] as PromiseRejectedResult).reason)`)
lines.push(' }')
})
lines.push('')
lines.push(' return hydration')
lines.push('}')
lines.push('')
return lines.join('\n')
}
/**
* Generate all djarea files.
*/
export async function generateDjareaFiles(schema, options = {}) {
const types = await generateDjareaTypes(schema)
const provider = generateDjareaProvider(schema, options)
const server = generateDjareaServer(schema)
const forms = generateDjareaForms(schema)
return { types, provider, server, forms }
}
/**
* Generate typed form hooks with Zod schemas.
*/
export function generateDjareaForms(schema) {
const functions = schema['x-djarea-functions'] || []
// Group form functions by form name
const formFunctions = functions.filter(fn => fn.isForm)
const formGroups = new Map()
for (const fn of formFunctions) {
const formName = fn.formName
if (!formGroups.has(formName)) {
formGroups.set(formName, { schema: null, validate: null, submit: null, formset: {} })
}
const group = formGroups.get(formName)
if (fn.formRole === 'schema') {
group.schema = fn
group.formFields = fn.formFields || []
} else if (fn.formRole === 'validate') {
group.validate = fn
} else if (fn.formRole === 'submit') {
group.submit = fn
} else if (fn.formRole === 'formset_schema') {
group.formset.schema = fn
} else if (fn.formRole === 'formset_validate') {
group.formset.validate = fn
} else if (fn.formRole === 'formset_submit') {
group.formset.submit = fn
}
}
if (formGroups.size === 0) {
return null
}
const lines = [
"'use client'",
'',
'// AUTO-GENERATED by djarea - do not edit manually',
'// Regenerate with: npm run schemas',
'',
'// Typed form hooks with Zod validation.',
'// Zod schemas are generated from Django form field definitions.',
'// Client-side validation matches Django constraints (required, max_length, email, etc.)',
'',
"import { z } from 'zod'",
"import {",
" useDjangoFormCore,",
" useDjangoFormsetCore,",
" type DjangoFormState,",
" type DjangoFormsetState,",
" type FormOptions,",
"} from 'djarea'",
'',
'// ============================================================================',
'// Zod Schemas',
'// ============================================================================',
'',
]
// Generate Zod schemas for each form
for (const [formName, group] of formGroups) {
if (!group.schema) continue
const pascalName = toPascalCase(formName)
const schemaName = `${pascalName}Schema`
const fields = group.formFields || []
lines.push(`/**`)
lines.push(` * Zod schema for ${formName} form`)
lines.push(` * Generated from Django form field definitions`)
lines.push(` */`)
lines.push(`export const ${schemaName} = z.object({`)
for (const field of fields) {
const zodField = generateZodField(field)
lines.push(` ${field.name}: ${zodField},`)
}
lines.push(`})`)
lines.push('')
}
// Generate TypeScript types from Zod schemas
lines.push('// ============================================================================')
lines.push('// Form Data Types (inferred from Zod schemas)')
lines.push('// ============================================================================')
lines.push('')
for (const [formName, group] of formGroups) {
if (!group.schema) continue
const pascalName = toPascalCase(formName)
const schemaName = `${pascalName}Schema`
const typeName = `${pascalName}FormData`
lines.push(`/** Form data type for ${formName}, inferred from Zod schema */`)
lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>`)
lines.push('')
}
lines.push('// ============================================================================')
lines.push('// Form Hooks')
lines.push('// ============================================================================')
lines.push('')
// Generate hooks for each form
for (const [formName, group] of formGroups) {
if (!group.schema) continue
const pascalName = toPascalCase(formName)
const hookName = `use${pascalName}Form`
const typeName = `${pascalName}FormData`
const schemaName = `${pascalName}Schema`
lines.push(`/**`)
lines.push(` * Typed form hook for ${formName}`)
lines.push(` *`)
lines.push(` * Features:`)
lines.push(` * - Full TypeScript inference for form fields`)
lines.push(` * - Client-side Zod validation (instant feedback)`)
lines.push(` * - Server-side Django validation (authoritative)`)
lines.push(` */`)
lines.push(`export function ${hookName}(`)
lines.push(` options?: FormOptions`)
lines.push(`): DjangoFormState<${typeName}> {`)
lines.push(` return useDjangoFormCore<${typeName}>({`)
lines.push(` name: '${formName}',`)
lines.push(` zodSchema: ${schemaName},`)
lines.push(` options,`)
lines.push(` })`)
lines.push(`}`)
lines.push('')
// Generate formset hook if formset is enabled
if (group.formset.schema) {
const formsetHookName = `use${pascalName}Formset`
lines.push(`/**`)
lines.push(` * Typed formset hook for ${formName}`)
lines.push(` */`)
lines.push(`export function ${formsetHookName}(`)
lines.push(` initialCount?: number,`)
lines.push(` liveValidation?: boolean`)
lines.push(`): DjangoFormsetState<${typeName}> {`)
lines.push(` return useDjangoFormsetCore<${typeName}>({`)
lines.push(` name: '${formName}',`)
lines.push(` zodSchema: ${schemaName},`)
lines.push(` initialCount,`)
lines.push(` liveValidation,`)
lines.push(` })`)
lines.push(`}`)
lines.push('')
}
}
// Export list of form names for reference
lines.push('// ============================================================================')
lines.push('// Form Registry')
lines.push('// ============================================================================')
lines.push('')
lines.push('export const DJANGO_FORMS = {')
for (const [formName, group] of formGroups) {
if (!group.schema) continue
const pascalName = toPascalCase(formName)
lines.push(` ${toCamelCase(formName)}: {`)
lines.push(` name: '${formName}',`)
lines.push(` schema: ${pascalName}Schema,`)
lines.push(` hook: 'use${pascalName}Form',`)
lines.push(` hasFormset: ${!!group.formset.schema},`)
lines.push(` },`)
}
lines.push('} as const')
lines.push('')
return lines.join('\n')
}
/**
* Generate a Zod field definition from Django field metadata.
*/
function generateZodField(field) {
const { zodType, required, constraints } = field
let zodCode = ''
// Base type
switch (zodType) {
case 'boolean':
zodCode = 'z.boolean()'
break
case 'number':
zodCode = 'z.number()'
if (constraints.int) {
zodCode += '.int()'
}
break
case 'array':
zodCode = `z.array(z.${constraints.items || 'string'}())`
break
case 'file':
zodCode = 'z.any()'
break
default:
zodCode = 'z.string()'
}
// Add constraints
if (zodType === 'string') {
if (constraints.email) {
zodCode += ".email('Invalid email address')"
} else if (constraints.url) {
zodCode += ".url('Invalid URL')"
}
if (constraints.regex) {
const escapedRegex = constraints.regex.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
const message = constraints.regexMessage || 'Invalid format'
zodCode += `.regex(new RegExp('${escapedRegex}'), '${message}')`
}
if (constraints.min !== undefined) {
zodCode += `.min(${constraints.min})`
}
if (constraints.max !== undefined) {
zodCode += `.max(${constraints.max})`
}
} else if (zodType === 'number') {
if (constraints.min !== undefined) {
zodCode += `.min(${constraints.min})`
}
if (constraints.max !== undefined) {
zodCode += `.max(${constraints.max})`
}
}
// Handle optional fields
if (!required) {
if (zodType === 'boolean') {
zodCode += '.default(false)'
} else {
zodCode += '.optional()'
}
}
return zodCode
}
/**
* Convert form name to PascalCase for type names.
*/
function toPascalCase(str) {
return str
.split(/[.\-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
/**
* Convert form name to camelCase for object keys.
*/
function toCamelCase(str) {
const pascal = toPascalCase(str)
return pascal.charAt(0).toLowerCase() + pascal.slice(1)
}
/**
* Convert camelCase to PascalCase.
*/
function pascalCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}

View File

@@ -0,0 +1,88 @@
/**
* Schema Fetching
*
* Fetches djarea and channels schemas from Django management commands.
*/
import { spawn } from 'child_process'
import path from 'path'
/**
* Run a Django management command and parse JSON output.
*/
function runDjangoCommand(source, cwd, command) {
const managePath = path.resolve(cwd, source.django.managePath)
const manageDir = path.dirname(managePath)
let cmd, args
if (source.django.command) {
cmd = source.django.command[0]
args = [...source.django.command.slice(1), 'manage.py', command, '--indent', '0']
} else {
const python = source.django.python || 'python'
cmd = python
args = [managePath, command, '--indent', '0']
}
const env = source.django.env
? { ...process.env, ...source.django.env }
: undefined
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, {
cwd: manageDir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
env,
})
let stdout = ''
let stderr = ''
proc.stdout.on('data', (data) => { stdout += data.toString() })
proc.stderr.on('data', (data) => { stderr += data.toString() })
proc.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Django command failed (exit ${code}):\n${stderr}`))
return
}
const jsonStart = stdout.indexOf('{')
if (jsonStart === -1) {
reject(new Error(`No JSON found in Django output:\n${stdout}\n${stderr}`))
return
}
try {
resolve(JSON.parse(stdout.slice(jsonStart)))
} catch (err) {
reject(new Error(`Failed to parse JSON from Django:\n${err.message}\n${stdout}`))
}
})
proc.on('error', (err) => {
reject(new Error(`Failed to spawn Django command: ${err.message}`))
})
})
}
/**
* Fetch channels schema from Django.
*/
export async function fetchChannelsSchema(source, cwd) {
if (!source.django) {
throw new Error('Channels schema export requires django source configuration')
}
return runDjangoCommand(source, cwd, 'export_channels_schema')
}
/**
* Fetch djarea schema from Django.
*/
export async function fetchDjareaSchema(source, cwd) {
if (!source.django) {
throw new Error('Djarea schema export requires django source configuration')
}
return runDjangoCommand(source, cwd, 'export_djarea_schema')
}

View File

@@ -0,0 +1,153 @@
/**
* Index File Generator
*
* Generates a consolidated index.ts that re-exports everything
* from the generated files for clean imports.
*/
/**
* Extract context hooks from djarea schema.
* Returns hook names in PascalCase (e.g., useAuthStatus, useUser).
*/
function extractContextHooks(djareaSchema) {
const functions = djareaSchema?.['x-djarea-functions'] || []
const contexts = functions.filter(fn => fn.isContext)
return contexts.map(ctx => {
const pascal = ctx.camelName.charAt(0).toUpperCase() + ctx.camelName.slice(1)
return `use${pascal}`
}).sort()
}
/**
* Generate the consolidated index.ts file.
*
* @param {Object} options - Generation options
* @param {Object} options.channelsSchema - Channels schema (optional)
* @param {Object} options.djareaSchema - Djarea schema (optional)
* @returns {string} Generated index.ts content
*/
export function generateIndex({ channelsSchema, djareaSchema }) {
const lines = [
'/**',
' * Djarea API - Consolidated Exports',
' *',
' * Import everything from here:',
' *',
' * @example',
' * ```tsx',
' * import {',
' * DjangoContext,',
' * useUser,',
' * useEcho,',
' * useChatChannel,',
' * DjangoError,',
' * } from \'@/api\'',
' * ```',
' */',
'',
'// AUTO-GENERATED by djarea - do not edit manually',
'// Regenerate with: npm run schemas',
'',
]
// ==========================================================================
// Djarea Provider & Hooks (from generated.django.tsx)
// ==========================================================================
const functions = djareaSchema?.['x-djarea-functions'] || []
const hasDjarea = functions.length > 0
if (hasDjarea) {
const contextHooks = extractContextHooks(djareaSchema)
const contexts = functions.filter(fn => fn.isContext)
const regularFunctions = functions.filter(fn => !fn.isContext && !fn.isForm)
lines.push('// =============================================================================')
lines.push('// Djarea Provider & Hooks')
lines.push('// =============================================================================')
lines.push('')
// Server exports (getDjangoHydration runs in server components)
if (contexts.length > 0) {
lines.push('export {')
lines.push(' getDjangoHydration,')
lines.push(' type DjangoHydration,')
lines.push("} from './generated.django.server'")
lines.push('')
}
// Client exports
lines.push('export {')
lines.push(' // Provider')
lines.push(' DjangoContext,')
lines.push(' type DjangoContextProps,')
if (contexts.length > 0) {
lines.push('')
lines.push(' // Context hooks')
for (const hookName of contextHooks) {
lines.push(` ${hookName},`)
}
lines.push('')
lines.push(' // Refresh hooks')
lines.push(' useDjangoRefresh,')
}
if (regularFunctions.length > 0) {
lines.push('')
lines.push(' // Function hooks')
for (const fn of regularFunctions) {
const pascal = fn.camelName.charAt(0).toUpperCase() + fn.camelName.slice(1)
lines.push(` use${pascal},`)
}
}
lines.push('')
lines.push(' // Re-exports from djarea library')
lines.push(' useDjarea,')
lines.push(' useDjareaStatus,')
lines.push(' usePush,')
lines.push(' DjangoError,')
lines.push(' type ConnectionStatus,')
lines.push(' type PushMessage,')
lines.push(' type PushListener,')
lines.push("} from './generated.django'")
lines.push('')
}
// ==========================================================================
// Channel Hooks (from generated.channels.hooks.tsx)
// ==========================================================================
const channels = channelsSchema?.['x-djarea-channels'] || []
if (channels.length > 0) {
lines.push('// =============================================================================')
lines.push('// Channel Hooks')
lines.push('// =============================================================================')
lines.push('')
lines.push('export {')
for (const ch of channels) {
lines.push(` use${ch.pascalName}Channel,`)
}
lines.push("} from './generated.channels.hooks'")
lines.push('')
// Channel types
lines.push('// =============================================================================')
lines.push('// Channel Types')
lines.push('// =============================================================================')
lines.push('')
lines.push('export type {')
for (const ch of channels) {
if (ch.hasParams) lines.push(` ${ch.paramsType},`)
if (ch.hasReactMessage) lines.push(` ${ch.reactMessageType},`)
if (ch.hasDjangoMessage) lines.push(` ${ch.djangoMessageType},`)
}
lines.push("} from './generated.channels'")
lines.push('')
}
return lines.join('\n')
}

115
react/src/index.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Djarea - Django Server Functions Client
*
* Frontend client for Django server functions.
* Server functions are the core primitive - accessed via React hooks.
*
* Two-layer architecture:
*
* 1. Library layer (this package) - Generic name-based API
* Used by libraries like Allauth that need to call functions by name.
*
* import { useDjarea, useDjareaContext, useDjareaCall } from 'djarea'
* const user = useDjareaContext('current_user')
* const call = useDjareaCall('update_profile')
*
* 2. Generated layer (@/api) - Typed project-specific API
* Used by product code for type-safe hooks.
*
* import { useCurrentUser, useUpdateProfile } from '@/api'
* const user = useCurrentUser()
* const updateProfile = useUpdateProfile()
*
* The generated code wraps DjareaProvider and adds type-safe hooks.
*/
// ============================================================================
// React Context & Hooks (primary API)
// ============================================================================
export {
// Provider
DjareaProvider,
type DjareaProviderProps,
type DjareaHydration,
// Hooks (generic name-based API for libraries)
useDjarea,
useDjareaContext,
useDjareaCall,
useDjareaStatus,
usePush,
// Types
type DjareaContextValue,
type ConnectionStatus,
type PushMessage,
type PushListener,
type ContextStore,
type Transport,
// Legacy aliases (deprecated, for migration)
DjangoContext,
useDjango,
useDjangoStatus,
useServerFunction,
type DjangoContextValue,
type DjangoContextProps,
} from './context'
// ============================================================================
// HTTP Client (for SSR or non-React usage)
// ============================================================================
export {
httpFunctionCall,
createDjangoCSRClient,
createDjangoSSRClient,
ensureDjangoSession,
Auth,
type DjangoHTTPClient,
type CSRClientConfig,
type JWTClientConfig,
type SSRClientConfig,
} from './client/'
// ============================================================================
// Errors
// ============================================================================
export {
DjangoError,
type FunctionErrorResponse,
type ErrorCode,
} from './errors'
// ============================================================================
// Forms (typed form hooks core)
// ============================================================================
export {
// Single form
useDjareaFormCore,
// Legacy alias
useDjareaFormCore as useDjangoFormCore,
type DjangoFormState,
type FormSchema,
type FormErrors,
type FormOptions,
type FormSubmitResult,
type FormCoreConfig,
// Formset
useDjareaFormsetCore,
// Legacy alias
useDjareaFormsetCore as useDjangoFormsetCore,
type DjangoFormsetState,
type FormsetSchema,
type FormsetErrors,
type FormsetCoreConfig,
type FormsetSubmitResult,
// Shared types
type FieldSchema,
type FieldChoice,
type FieldError,
type FormMeta,
} from './forms'

View File

@@ -0,0 +1,235 @@
'use client'
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
type ReactNode,
} from 'react'
import type { JWTTokens, JWTConfig, JWTState } from '../client/types'
function getCSRFToken(): string | null {
if (typeof document === 'undefined') return null
const match = document.cookie.match(/csrftoken=([^;]+)/)
return match?.[1] ?? null
}
const Context = createContext<JWTState | null>(null)
const DEFAULT_CONFIG: Required<JWTConfig> = {
baseUrl: '',
endpoint: '/api/djarea/call/',
refreshBuffer: 30,
autoObtain: true,
autoRefresh: true,
}
interface JWTContextProps {
children: ReactNode
config?: JWTConfig
}
export function JWTContext({ children, config }: JWTContextProps) {
const cfg = { ...DEFAULT_CONFIG, ...config }
const [tokens, setTokens] = useState<JWTTokens | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Helper to call server functions
const callServerFunction = useCallback(async (fn: string, args: Record<string, unknown> = {}) => {
const url = cfg.baseUrl ? `${cfg.baseUrl}${cfg.endpoint}` : cfg.endpoint
const csrfToken = getCSRFToken()
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
},
credentials: 'include', // Include session cookie for CSRF
body: JSON.stringify({ fn, args }),
})
const data = await response.json()
if (data.error) {
const err = new Error(data.message || 'Server function failed')
;(err as any).code = data.code
;(err as any).details = data.details
throw err
}
return data.data
}, [cfg.baseUrl, cfg.endpoint])
// Obtain tokens from session
const obtainTokens = useCallback(async (): Promise<JWTTokens | null> => {
setIsLoading(true)
setError(null)
try {
const result = await callServerFunction('jwt_obtain')
const newTokens: JWTTokens = {
accessToken: result.access_token,
refreshToken: result.refresh_token,
expiresAt: Date.now() + result.expires_in * 1000,
}
setTokens(newTokens)
return newTokens
} catch (err: any) {
// FORBIDDEN means not authenticated - expected, not an error
if (err.code === 'FORBIDDEN') {
setTokens(null)
return null
}
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
return null
} finally {
setIsLoading(false)
}
}, [callServerFunction])
// Refresh tokens
const refreshTokens = useCallback(async (): Promise<JWTTokens | null> => {
if (!tokens?.refreshToken) {
return null
}
setIsLoading(true)
setError(null)
try {
const result = await callServerFunction('jwt_refresh', {
refresh_token: tokens.refreshToken,
})
const newTokens: JWTTokens = {
accessToken: result.access_token,
refreshToken: result.refresh_token,
expiresAt: Date.now() + result.expires_in * 1000,
}
setTokens(newTokens)
return newTokens
} catch (err: any) {
// FORBIDDEN means refresh token invalid/expired - clear tokens
if (err.code === 'FORBIDDEN') {
setTokens(null)
return null
}
const error = err instanceof Error ? err : new Error(String(err))
setError(error)
return null
} finally {
setIsLoading(false)
}
}, [tokens?.refreshToken, callServerFunction])
// Clear tokens
const clearTokens = useCallback(() => {
setTokens(null)
setError(null)
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current)
refreshTimeoutRef.current = null
}
}, [])
// Get access token (refresh if needed)
const getAccessToken = useCallback(async (): Promise<string | null> => {
if (!tokens) {
return null
}
// Check if token needs refresh
const bufferMs = cfg.refreshBuffer * 1000
if (tokens.expiresAt - Date.now() < bufferMs) {
const newTokens = await refreshTokens()
return newTokens?.accessToken ?? null
}
return tokens.accessToken
}, [tokens, cfg.refreshBuffer, refreshTokens])
// Auto-obtain on mount
useEffect(() => {
if (!cfg.autoObtain) return
obtainTokens()
}, [cfg.autoObtain, obtainTokens])
// Auto-refresh before expiry
useEffect(() => {
if (!cfg.autoRefresh || !tokens) {
return
}
// Clear existing timeout
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current)
}
// Schedule refresh
const bufferMs = cfg.refreshBuffer * 1000
const timeUntilRefresh = tokens.expiresAt - Date.now() - bufferMs
if (timeUntilRefresh > 0) {
refreshTimeoutRef.current = setTimeout(() => {
refreshTokens()
}, timeUntilRefresh)
}
return () => {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current)
}
}
}, [cfg.autoRefresh, cfg.refreshBuffer, tokens, refreshTokens])
const value: JWTState = {
tokens,
isLoading,
error,
obtainTokens,
refreshTokens,
clearTokens,
getAccessToken,
}
return <Context value={value}>{children}</Context>
}
/**
* Hook to access JWT state and methods.
*
* When used outside JWTContext, returns null. This allows
* conditional JWT usage (e.g., useDjangoApi({ jwt: true }))
* without requiring JWTContext to always be present.
*/
export function useJWT(): JWTState | null {
return useContext(Context)
}
/**
* Hook to access JWT state with a guarantee it exists.
* Throws if used outside JWTContext.
*/
export function useJWTRequired(): JWTState {
const context = useContext(Context)
if (!context) {
throw new Error('useJWTRequired must be used within JWTContext')
}
return context
}
/** Check if JWT is available (tokens obtained) */
export function useJWTReady(): boolean {
const jwt = useJWT()
if (!jwt) return false
return !jwt.isLoading && jwt.tokens !== null
}

View File

@@ -0,0 +1,152 @@
/**
* Integration tests for JWT Context
*
* These tests call the REAL backend - no mocks.
* Backend must be running: docker-compose up
*
* Run with: RUN_INTEGRATION_TESTS=true npm run test
*
* Note: Most JWT operations require an authenticated session.
* Tests that require authentication verify 401 handling (expected for anonymous users).
*/
import { renderHook, act, waitFor } from '@testing-library/react'
import { ReactNode } from 'react'
import { JWTContext, useJWT, useJWTRequired } from '../JWTContext'
import { describeIntegration, BACKEND_URL } from '../../testing'
function createWrapper(config?: Parameters<typeof JWTContext>[0]['config']) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<JWTContext config={{
autoObtain: false,
baseUrl: BACKEND_URL,
...config
}}>
{children}
</JWTContext>
)
}
}
describeIntegration('JWTContext (integration)', () => {
describe('Hook behavior outside provider', () => {
it('should return null when useJWT used outside JWTContext', () => {
const { result } = renderHook(() => useJWT())
expect(result.current).toBeNull()
})
it('should throw when useJWTRequired used outside JWTContext', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
renderHook(() => useJWTRequired())
}).toThrow('useJWTRequired must be used within JWTContext')
consoleSpy.mockRestore()
})
})
describe('Token operations with real backend', () => {
it('should handle 401 when obtaining tokens as anonymous user', async () => {
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper(),
})
await act(async () => {
const tokens = await result.current.obtainTokens()
// Anonymous users get 401, which returns null (not an error)
expect(tokens).toBeNull()
})
expect(result.current.tokens).toBeNull()
// 401 is graceful - not an error state
expect(result.current.error).toBeNull()
})
it('should handle 401 when refreshing tokens without valid refresh token', async () => {
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper(),
})
// Try to refresh without any tokens - should fail gracefully
await act(async () => {
const tokens = await result.current.refreshTokens()
expect(tokens).toBeNull()
})
expect(result.current.tokens).toBeNull()
})
})
describe('clearTokens (no backend needed)', () => {
it('should be safe to call clearTokens when no tokens are set', async () => {
// Verifies clearTokens doesn't throw or corrupt state when called
// with no tokens present (e.g., during logout when already logged out)
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper(),
})
act(() => {
result.current.clearTokens()
})
expect(result.current.tokens).toBeNull()
expect(result.current.error).toBeNull()
})
})
describe('getAccessToken', () => {
it('should return null when no tokens available', async () => {
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper(),
})
let token: string | null = 'not-null'
await act(async () => {
token = await result.current.getAccessToken()
})
expect(token).toBeNull()
})
})
describe('autoObtain with real backend', () => {
it('should attempt auto-obtain and handle 401 for anonymous', async () => {
const { result } = renderHook(() => useJWTRequired(), {
wrapper: createWrapper({ autoObtain: true }),
})
// Auto-obtain will attempt to get tokens but fail for anonymous user
await waitFor(() => {
// After auto-obtain completes, tokens should be null (401 response)
// or the loading state should be done
expect(result.current.isLoading).toBe(false)
}, { timeout: 5000 })
// Anonymous user won't have tokens
expect(result.current.tokens).toBeNull()
})
})
})
/**
* Note on authenticated JWT tests:
*
* To fully test JWT token obtain/refresh, we would need:
* 1. An authenticated user session (login first)
* 2. Valid CSRF token handling
*
* These scenarios are better tested in E2E tests (Playwright/Cypress)
* where we can:
* 1. Navigate to login page
* 2. Submit credentials
* 3. Then test JWT token flows
*
* The tests above verify:
* - Hook API contract (throws/returns null outside provider)
* - Graceful 401 handling (anonymous users)
* - State management (clearTokens)
* - Integration with real backend (network calls happen)
*/

View File

@@ -0,0 +1,79 @@
/**
* Contract Tests for Djarea JWT Server Functions
*
* Validates that the backend schema exports the expected JWT functions.
* These tests catch frontend/backend contract mismatches early.
*/
import djareaSchema from '@/api/generated.djarea.schema.json'
type DjareaFunction = {
name: string
camelName: string
hasInput: boolean
inputType: string | null
outputType: string
transport: string
}
function getFunctions(): DjareaFunction[] {
return (djareaSchema as any)['x-djarea-functions'] ?? []
}
function findFunction(name: string): DjareaFunction | undefined {
return getFunctions().find(fn => fn.name === name)
}
describe('JWT Server Functions Contract', () => {
describe('jwt_obtain', () => {
it('should be registered as a server function', () => {
const fn = findFunction('jwt_obtain')
expect(fn).toBeDefined()
expect(fn?.transport).toBe('http')
})
it('should have no input (session-based)', () => {
const fn = findFunction('jwt_obtain')
expect(fn?.hasInput).toBe(false)
})
it('should return token pair with expected fields', () => {
const schemas = (djareaSchema as any).components?.schemas
const output = schemas?.jwtObtainOutput
expect(output).toBeDefined()
expect(output.properties.access_token).toBeDefined()
expect(output.properties.refresh_token).toBeDefined()
expect(output.properties.expires_in).toBeDefined()
})
})
describe('jwt_refresh', () => {
it('should be registered as a server function', () => {
const fn = findFunction('jwt_refresh')
expect(fn).toBeDefined()
expect(fn?.transport).toBe('http')
})
it('should accept refresh_token as input', () => {
const fn = findFunction('jwt_refresh')
expect(fn?.hasInput).toBe(true)
const schemas = (djareaSchema as any).components?.schemas
const input = schemas?.jwtRefreshInput
expect(input).toBeDefined()
expect(input.properties.refresh_token).toBeDefined()
})
it('should return token pair with expected fields', () => {
const schemas = (djareaSchema as any).components?.schemas
const output = schemas?.jwtRefreshOutput
expect(output).toBeDefined()
expect(output.properties.access_token).toBeDefined()
expect(output.properties.refresh_token).toBeDefined()
expect(output.properties.expires_in).toBeDefined()
})
})
})

View File

@@ -0,0 +1,34 @@
/**
* Unit Tests for JWT Hooks
*
* Tests hook behavior in isolation (no backend required).
*/
import { renderHook } from '@testing-library/react'
import { ReactNode } from 'react'
import { JWTContext, useJWTReady } from '../JWTContext'
// Wrapper that provides JWTContext
function createWrapper(config?: Parameters<typeof JWTContext>[0]['config']) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<JWTContext config={{ autoObtain: false, ...config }}>
{children}
</JWTContext>
)
}
}
describe('useJWTReady', () => {
it('returns false outside JWTContext', () => {
const { result } = renderHook(() => useJWTReady())
expect(result.current).toBe(false)
})
it('returns false when no tokens', () => {
const { result } = renderHook(() => useJWTReady(), {
wrapper: createWrapper(),
})
expect(result.current).toBe(false)
})
})

79
react/src/jwt/index.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* djarea/jwt
*
* JWT token management via djarea server functions.
* Handles token lifecycle: obtain, refresh, clear.
*
* ## Quick Start
*
* Use JWTContext in authenticated areas (e.g., inside UserRoute):
*
* ```tsx
* import { JWTContext } from 'djarea/jwt'
* import { UserRoute } from 'djarea/allauth'
*
* function ProtectedPage() {
* return (
* <UserRoute>
* <JWTContext>
* <MyProtectedContent />
* </JWTContext>
* </UserRoute>
* )
* }
* ```
*
* Then use JWT-authenticated requests:
*
* ```tsx
* import { useDjangoCSRClient, Auth } from 'djarea/client/react'
*
* function MyProtectedContent() {
* const client = useDjangoCSRClient(Auth.JWT)
*
* const fetchData = async () => {
* const response = await client.request('GET', '/api/protected/')
* return response.json()
* }
* }
* ```
*
* ## How It Works
*
* 1. JWTContext calls jwt_obtain server function (via /api/djarea/call/)
* 2. If not authenticated, returns FORBIDDEN (tokens stay null)
* 3. Client uses getAccessToken() for Bearer token injection
* 4. Tokens auto-refresh via jwt_refresh server function
* 5. On logout, call clearTokens()
*
* ## Configuration
*
* ```tsx
* <JWTContext
* config={{
* endpoint: '/api/djarea/call/', // default
* refreshBuffer: 30, // refresh 30s before expiry
* autoObtain: true, // obtain on mount
* autoRefresh: true, // auto-refresh before expiry
* }}
* >
* ```
*
* ## Manual Token Management
*
* ```tsx
* import { useJWT } from 'djarea/jwt'
*
* function LogoutButton() {
* const jwt = useJWT()
*
* const handleLogout = async () => {
* await fetch('/api/logout/', { method: 'POST' })
* jwt?.clearTokens()
* }
* }
* ```
*/
export { JWTContext, useJWT, useJWTRequired, useJWTReady } from './JWTContext'
export type { JWTTokens, JWTConfig, JWTState } from '../client/types'

42
react/src/testing.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Integration Test Helper
*
* Integration tests require a running backend: docker-compose up
*
* To run integration tests:
* RUN_INTEGRATION_TESTS=true npm run test
*
* By default, integration tests are skipped in the regular test run.
*/
export const runIntegrationTests = process.env.RUN_INTEGRATION_TESTS === 'true'
// Type for Jest's describe function (simplied, avoids needing @types/jest at build time)
type DescribeFn = {
(name: string, fn: () => void): void
skip: (name: string, fn: () => void) => void
}
// Declare global describe from Jest (only available in test environment)
declare const describe: DescribeFn
/**
* Use this instead of `describe` for integration test suites that require a backend.
* Tests will be skipped unless RUN_INTEGRATION_TESTS=true.
*/
export const describeIntegration = runIntegrationTests ? describe : describe.skip
/**
* Backend URL from environment or default localhost
*/
export const BACKEND_URL = (() => {
if (!process.env.NEXT_PUBLIC_HOST_URL) {
console.warn('[djarea/testing] NEXT_PUBLIC_HOST_URL not set, falling back to http://localhost')
}
return process.env.NEXT_PUBLIC_HOST_URL || 'http://localhost'
})()
/**
* WebSocket URL derived from backend URL
*/
export const WS_URL = BACKEND_URL.replace(/^http/, 'ws') + '/ws/'

23
react/tsconfig.build.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"djarea": ["./src/index.ts"],
"djarea/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["src/**/__tests__/**", "src/**/*.test.*"]
}

14
react/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"types": ["vitest/globals"]
},
"include": ["src"]
}

27
react/vitest.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'djarea/channels': path.resolve(__dirname, 'src/channels/index.ts'),
'djarea/client/react': path.resolve(__dirname, 'src/client/react.ts'),
'djarea/client/nextjs': path.resolve(__dirname, 'src/client/nextjs.tsx'),
'djarea/client': path.resolve(__dirname, 'src/client/index.ts'),
'djarea/jwt': path.resolve(__dirname, 'src/jwt/index.ts'),
'djarea/allauth/nextjs': path.resolve(__dirname, 'src/allauth/nextjs.tsx'),
'djarea/allauth': path.resolve(__dirname, 'src/allauth/index.ts'),
'djarea': path.resolve(__dirname, 'src/index.ts'),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
exclude: [
// Requires @/api/generated.djarea.schema.json from consuming project
'src/jwt/__tests__/contract.test.ts',
],
},
})

6
react/vitest.setup.ts Normal file
View File

@@ -0,0 +1,6 @@
import { vi } from 'vitest'
import '@testing-library/jest-dom/vitest'
// Jest compatibility: existing tests use jest.fn(), jest.spyOn(), jest.mock()
// Vitest's `vi` object has the same API, so we alias it globally.
;(globalThis as any).jest = vi