Allauth extracted to its own repository (~/dev/mizan-allauth)
The auth-provider concern becomes a dedicated Django system. Removed from mizan: the Django integration (mizan/integrations/allauth — auth contexts + ~15 form wrappers), the legacy/ pre-kernel TypeScript client, the allauth and webauthn dependency extras (fido2 was consumed only by the WebAuthn form wrappers), and the HEADLESS_JWT_* settings fallbacks — the allauth-headless compat seam belongs to the dedicated system, not to mizan's JWT module. Duplicate-name registration in discovery now surfaces a warning instead of passing silently. README claims updated to point at mizan-allauth; the root README's hand-maintained status matrix collapsed into the tests/afi conformance suite as the parity authority. OWED_SURFACE.md refreshed against the post-extraction tree (22 units). mizan-django suite: 350 passed, 21 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
459
OWED_SURFACE.md
Normal file
459
OWED_SURFACE.md
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
# Owed Surface
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
This document declares, per crate/package unit, the behavioral mechanisms the unit owes to substantiate the documentation's claims.
|
||||||
|
|
||||||
|
- The owed surface is derived from the documentation's CLAIMS. For each non-trivial claim, it enumerates the behaviors the code must exhibit to prove the claim — to a hostile auditor, an IP lawyer, and a paying customer — at maximal performance, efficiency, and hygiene, never a minimal technicality.
|
||||||
|
- A mechanism is stated as observable behavior with the criterion that distinguishes its maximal realization from a degenerate stub, observably enough that a skeptic can check it.
|
||||||
|
- A mechanism is owed whether or not the current source realizes it; an unbuilt claim is surfaced, not dropped.
|
||||||
|
- A **unit** is one crate/package/build target, identified by its root path, sized to emit whole in one `.pack`. A `.pack` targets the units whose roots contain its files.
|
||||||
|
- This is a declared contract the authoring agent holds and honors when it emits a pack. The PreToolUse gate enforces only that a stance is declared (this document, or an exemption) before code is authored; it does not test the packs.
|
||||||
|
|
||||||
|
The AFI's single load-bearing thesis (README.md, docs/AFI_ARCHITECTURE.md § Why the AFI shape): the backends × frontends quadratic collapses to linear because **one KDL IR is the only contract that crosses the backend↔frontend boundary**. Every mechanism below is, in the end, in service of that: N backends emit byte-identical KDL for the same registered functions, and M frontends are generated from it, so a bug can only live in the KDL contract or its edges — nowhere in between.
|
||||||
|
|
||||||
|
`backends/mizan-django/src/mizan` exceeds the single-emit budget (137K est. tokens); it is decomposed below into sub-units along the documented feature seams (dispatch, cache, channels, forms, shapes, ssr, jwt, registration/export) plus its verification harness, which itself exceeds budget and is cut into two test sub-units at the protocol-vs-adversarial seam. Every other unit emits whole.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan_core (`cores/mizan-python/src/mizan_core`)
|
||||||
|
|
||||||
|
**Charter.** The framework-agnostic Python substrate every Python backend adapter stands on: the `@client` decorator and the function-to-IR machinery, the registry, canonical KDL IR emission, HMAC cache-key derivation, cache backends, MWT identity, and the type-introspection helpers the adapters share. It owns *language-level* primitives; it does not own transport, dispatch, or any Django/FastAPI mechanics.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Client Function RPC — decorated functions carrying the full variadic/kwarg set (INVARIANTS.md § Client Function RPC).
|
||||||
|
- Named Contexts — functions sharing a context name grouped at registration into one provider/one fetch (INVARIANTS.md § Named Contexts; MIZAN.md §1–2).
|
||||||
|
- Mutation Invalidation & merge — `affects=`/`merge=` carried in the IR, never middleware (INVARIANTS.md § Mutation Invalidation; MIZAN.md §4).
|
||||||
|
- Auth as a property of the declared function, carried in the IR (INVARIANTS.md § Auth; MWT_SPEC.md § Usage rule).
|
||||||
|
- Canonical KDL IR — every backend emits KDL describing functions/contexts/types/invalidation graph; the IR is the only contract (INVARIANTS.md § Canonical IR & Codegen; docs/AFI_ARCHITECTURE.md § KDL is the IR).
|
||||||
|
- HMAC cache keying with cross-language conformance (docs/CACHE_KEYING.md; ROADMAP.md § HMAC cache keying).
|
||||||
|
- MWT identity layer (docs/MWT_SPEC.md).
|
||||||
|
- Free origin-side cache implementing the full protocol locally (docs/PRODUCT_ARCHITECTURE.md § Free framework).
|
||||||
|
- File Uploads — `Upload` first-class end to end through IR (INVARIANTS.md § File Uploads).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
|
||||||
|
Client Function RPC / decorator:
|
||||||
|
- `@client` accepts the full declared set (`context`, `affects`, `merge`, `private`, `route`, `methods`, `websocket`, `auth`, `rev`, `cache`) and synthesizes a Pydantic `Input` model from the function signature (skipping the request param) — observable: a decorated fn with `(request, a: int, b: int)` yields an `Input` with two typed fields; input validation rejects `a="x"` before the body runs.
|
||||||
|
- the return annotation decides wire shape: a primitive/dict return is wrapped as `{result: …}`, while `BaseModel` / `list[BaseModel]` / `Optional[BaseModel]` pass through bare — observable: `-> list[Item]` reaches the wire as a bare JSON array, `-> int` as `{"result": n}`; a missing return annotation raises `TypeError` at decoration (not a silent `Any`).
|
||||||
|
- `context=` and `affects=` (and `merge=`) are enforced mutually exclusive at decoration — observable: `@client(context=X, affects=Y)` raises `ValueError`, so a function cannot be simultaneously a reader and a mutation.
|
||||||
|
- `auth=` is normalized and validated at decoration (`True`→`"required"`, callables kept, `"staff"/"superuser"` allowed) — observable: `@client(auth="admin")` raises `ValueError` naming the valid set, not a runtime surprise at dispatch.
|
||||||
|
|
||||||
|
Named Contexts grouping (the "one provider, one fetch" invariant's registry half):
|
||||||
|
- the registry groups every function by its context string so a named context is a single fetch unit, never N callables — observable: two `@client(context="user")` functions produce `get_context_groups()["user"] == [both names]`; `"global"` is just a reserved name in the same map, not a separate mechanism.
|
||||||
|
- OWED (unbuilt): mixing socket and non-socket transport within one context is a registration-time error (INVARIANTS.md § WebSocket Support) — observable when built: registering a `websocket=True` fn and a plain fn under the same `context=` raises at registration; no such check exists in the registry today, so this obligation is currently unsubstantiated.
|
||||||
|
- OWED (unbuilt): `receive` defined without `send`, and `affects` referencing a non-existent context/function, are registration-time errors (MIZAN.md §6) — `validate_registry()` warns on unresolved `affects` targets but does not hard-error, and there is no `send`/`receive` class to validate.
|
||||||
|
|
||||||
|
Canonical KDL IR (`build_ir`) — the contract every codegen target reads:
|
||||||
|
- IR is emitted in a canonical order independent of registration order (functions alphabetical by wire name, contexts alphabetical, params alphabetical, `shared-by` sorted) — observable: registering the same functions in two different orders yields byte-identical KDL; this is the property the three-way parity test rests on.
|
||||||
|
- types are introspected from the Pydantic models directly (never routed through JSON-Schema `$ref`), producing `struct` / `alias{list}` / `enum` / `optional` / `union` shapes under canonical `<camelName>Input` / `<camelName>Output` names, with `Vec`-element sub-types surfaced — observable: a `-> list[OrderOutput]` fn emits `type "userOrdersOutput" { alias { list { ref "OrderOutput" } } }` AND a `type "OrderOutput" { struct … }`; `-> Model | None` sets `output-nullable #true`.
|
||||||
|
- context param elevation is computed in the IR: a param is `required #true` iff every member of the context declares it, with `shared-by` naming the declarers — observable: a two-function `user` context where both take `user_id` emits `param "user_id" { type "integer"; required #true; shared-by … }`; if only one declares `page`, `page` is `required #false`.
|
||||||
|
- `private` and view-path functions are omitted from the emitted `function` set, and channels are emitted from the `channels` registry extension — observable: `@client(private=True)` never appears in the KDL (so it can carry invalidation without being client-callable); a registered channel emits a `channel` node with its pascal-name and message-type refs.
|
||||||
|
|
||||||
|
HMAC cache keying (protocol-critical cross-language identity):
|
||||||
|
- `derive_cache_key` produces `ctx:{context}:{hmac_hex}` over a JSON-canonical sorted form with param values normalized to JSON-native strings (`True`→`"true"`, `None`→`"null"`) and `user_id` omitted for public content — observable: the pinned test vectors (`ctx:user:605a1ca5…` public, `ctx:user:30fc08eb…` user-scoped) match the TypeScript adapter byte-for-byte; param ordering does not change the key; the `ctx:` prefix supports broad SCAN.
|
||||||
|
- key derivation resists delimiter collision and versions on `rev` — observable: `context="user", user_id="12"` and `context="user1", user_id="2"` produce different keys; bumping `rev` produces a new key, so old entries become unreachable orphans without a purge.
|
||||||
|
|
||||||
|
MWT identity layer:
|
||||||
|
- `create_mwt` places `kid` in the JOSE header per RFC 7515 (not the payload) and computes `pkey` as `sha256` over `sorted(get_all_permissions())` plus staff/super flags, with `aud` and `nbf` claims — observable: `decode_mwt` reads `kid` from the header; a token minted for one audience decodes to `None` under another; `pkey` is deterministic for identical permission state and changes the instant a permission is added.
|
||||||
|
- `MWTUser` is built entirely from claims with no DB query — observable: constructing `MWTUser(payload)` sets `pk`/`is_staff`/`is_superuser`/`pkey` from the token alone; an expired token decodes to `None`.
|
||||||
|
|
||||||
|
Cache backends:
|
||||||
|
- `MemoryCache` and `RedisCache` both implement get/set/delete plus prefix-scoped purge; the Redis broad purge SCANs `ctx:{context}:*` and UNLINKs, never a full flush — observable: `delete_by_prefix("ctx:user:")` removes only `user` entries and leaves `ctx:products:*` and foreign-prefixed keys intact; `RedisCache` applies a TTL safety-net on every `set`.
|
||||||
|
|
||||||
|
Type-introspection helpers (shared so backend parity cannot drift):
|
||||||
|
- `is_structured_output` recognizes `BaseModel` / `Optional[BaseModel]` / container-of-`BaseModel` as no-wrap, and `types_match_for_merge` accepts direct / list-upsert / list-replace shape matches — observable: a slot typed `list[T]` matches a value typed `T` (upsert-by-id), and a multi-arm `A | B | None` union is returned as-is by `extract_optional`, not silently narrowed to one arm.
|
||||||
|
|
||||||
|
File Uploads — OWED (unbuilt):
|
||||||
|
- an `Upload` type is a first-class argument carried through IR, codegen, and dispatch binding, bound from multipart over HTTP and from the envelope over IPC (INVARIANTS.md § File Uploads) — observable when built: a function declaring an `Upload` parameter emits a distinguished IR shape and binds a real file object at dispatch; no `Upload` type exists anywhere in the source today, so this claim is entirely unsubstantiated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust core (`cores/mizan-rust`)
|
||||||
|
|
||||||
|
**Charter.** The Rust analog of `mizan_core`: the IR data model, a KDL emitter that is byte-equivalent to the Python emitter, the compile-time (linkme) registry, the runtime invalidation/merge resolvers the HTTP and Tauri adapters call, and the cross-function graph checks. It owns the Rust side of the *same* IR contract; it does not own transport.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Canonical KDL IR — "the IR must be validated against multiple adapters"; Rust is an IR authority (docs/AFI_ARCHITECTURE.md § KDL is the IR; README.md note 6).
|
||||||
|
- Mutation invalidation auto-scoping (three-tier) and merge on the Rust adapters (README.md § Adapters; § Merge via `mizan-tauri`/`mizan-rust-axum`).
|
||||||
|
- The IR is the only contract — divergence between adapters is what it exists to prevent (docs/AFI_ARCHITECTURE.md § KDL is the IR).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
|
||||||
|
Byte-equivalent KDL emission:
|
||||||
|
- `build_ir()` produces KDL byte-identical to the Python emitter against the same registered functions/types/contexts — observable: `cores/mizan-rust/tests/afi_parity.rs` and the three-way `tests/afi/test_codegen_parity.py` diff Rust output against the canonical Python-emitted `afi_ir.kdl` and require exact equality (line-by-line failure on any drift).
|
||||||
|
- the emitter reproduces the Python emitter's canonicalization exactly: alphabetical functions/contexts, sorted params, `shared-by`, snake→camel conversion, primitive-alias/enum inlining, and tree-shaking to types reachable from a registered function's input/output — observable: a `#[derive(Mizan)]` type not referenced by any function is omitted; an `Alias(Primitive)` or `Enum` named type inlines at its reference site instead of emitting a standalone `type` node, matching the Python output.
|
||||||
|
|
||||||
|
Compile-time registry:
|
||||||
|
- `TYPES` / `CONTEXTS` / `FUNCTIONS` are linkme distributed slices populated at the consumer crate's expansion sites, and `lookup_function` / `context_members` resolve against them — observable: an IR-export bin that references one symbol per module force-links its registrations; dropping the reference drops the function from the emitted IR (the documented force-link requirement is real, not decorative).
|
||||||
|
|
||||||
|
Runtime invalidation & merge (must match the Python executor's semantics):
|
||||||
|
- `compute_invalidation` auto-scopes by matching mutation arg names against the affected context's declared Input params — observable: a mutation carrying `user_id` against a `user` context whose members declare `user_id` emits `{context:"user", params:{user_id:…}}`, while a non-matching arg emits the bare context string.
|
||||||
|
- `compute_merges` resolves the slot by structural return-type match against context members (via `types_match`), emitting `{context, slot, value}` only on a unique match and dropping ambiguous/no-match — observable: with two context members of different output shapes, a mutation's value routes to the single member whose type matches; two matching members drop the merge (fall back to refetch), never a bundle-order guess.
|
||||||
|
|
||||||
|
Cross-function graph checks (fail at IR-build time, before any client is emitted):
|
||||||
|
- `verify_invariants` panics with a structured message when an `affects`/`merge` target names an unregistered context, when a `merge` target has no unique matching member, or when a shared context param's type diverges across members — observable: an `affects = "ghost"` fails codegen with a named error; a `merge` whose context has two same-type members fails naming both; this is the whole-graph consistency the "IR prevents divergence" claim rests on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust-macros (`cores/mizan-rust-macros`)
|
||||||
|
|
||||||
|
**Charter.** The proc macros — `#[derive(Mizan)]`, `#[mizan::context]`, `#[mizan::client]` — that make the Rust consumer surface author the same registry and IR shapes the Python decorator produces. It owns the compile-time codegen that emits `MizanType`/`FunctionSpec` impls and linkme registrations; it does not own runtime behavior.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Rust/Tauri are "the IR authority via the `#[mizan::client]` macro + linkme registry" (README.md note 6).
|
||||||
|
- The `#[mizan::client]` surface mirrors the Python `@client` parameter set (backends/mizan-tauri/README.md § Define server functions; backends/mizan-rust-axum README).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `#[derive(Mizan)]` emits a `MizanType::shape()` matching the Python type introspection, honoring serde `rename_all`/`rename` so wire names match serialization, and registers a `TypeEntry` — observable: an enum with `#[serde(rename_all="snake_case")]` emits IR enum variants in snake form; a struct field `r#type` emits IR field name `type`.
|
||||||
|
- `#[mizan::client]` synthesizes a `<camelName>Input` struct + `MizanType` impl, registers the canonical `<camelName>Input`/`<camelName>Output` type entries (and the `Vec` element type for list outputs), and implements `FunctionSpec::dispatch` that deserializes JSON args into the typed input, awaits the body, and serializes the result — observable: `async fn user_orders(req, user_id: i64) -> Vec<OrderOutput>` registers `userOrdersOutput` as a list alias plus `OrderOutput`, and dispatch round-trips typed args; a `Result<T, MizanError>` return `?`-unwraps so user errors surface as the standard envelope, while the IR still sees only the `T` shape.
|
||||||
|
- `#[mizan::client]` enforces the same mutual-exclusion as Python (`context` vs `affects`/`merge`) and requires an `async fn` with an explicit return type — observable: `#[mizan::client(context = X, affects = Y)]` is a compile error; a non-async or return-typeless fn is a compile error.
|
||||||
|
- `#[mizan::context]` emits a `ContextMarker` with a snake_case (or explicit) name and registers a `ContextEntry` — observable: `#[mizan::context("user")]` and `#[mizan::context] struct UserCtx` both yield `NAME == "user"`; a non-unit struct is a compile error.
|
||||||
|
- input-param wire names strip the Rust `_`-underscore convention and bridge it with `#[serde(rename)]` — observable: `_user_id: i64` emits IR param name `user_id` and the synthesized Input renames the JSON key so dispatch deserializes the wire form.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust-ssr (`cores/mizan-rust-ssr`)
|
||||||
|
|
||||||
|
**Charter.** The embedded-V8 SSR engine and the anti-RSC guard. It owns rendering a build-time JS bundle to HTML in-process via `deno_core`, and the structural guarantee that the SSR surface never imports an RSC/Flight runtime. It does not own the Django template backend (that is `mizan-django/ssr`).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- SSR is hand-rolled; no frontend adapter imports an SSR runtime or meta-framework (Next/Nuxt/SvelteKit/RSC/Flight) — the CVE-2025-55182 pre-auth-RCE deserialization class (HOLOMORPHICS/Mizan project note; MEMORY: mizan-ssr-no-framework-runtimes; enforced by `cores/mizan-rust-ssr/tests/no_rsc.rs`).
|
||||||
|
- SSR renders synchronously from props, injected as validated data (the AFI provides the typed one-way version).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the engine composes a real `deno_web` web-platform layer (TextEncoder/Decoder, MessagePort, timers) rather than a partial shim, evals the trusted bundle once, and renders per request — observable: the fixture bundle renders `Hello, World!`; a missing global would fail loudly at render, not silently pass (the doc's "partial polyfill is silent-failure-shaped" concern is discharged by using deno_web's real impls).
|
||||||
|
- props cross as a `v8::json::parse`d value passed as a function argument, never spliced into evaluated source — observable: the injection test feeds a prop string crafted to break out of a string-built call; it renders as inert text and does not set a global, so code injection is structurally absent.
|
||||||
|
- the no-RSC guard scans authored SSR source and dependencies for the forbidden token set (`react-server-dom`, `renderToReadableStream`, `renderToPipeableStream`, `createFromReadableStream`/`Fetch`, `use server`, `next/`, `nuxt`, `@sveltejs/kit`) and fails on presence — observable: adding any RSC/Flight/meta-framework import to the scanned fixtures turns `no_rsc.rs` red; absence alone is not the guarantee — re-entry is loud.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django dispatch (`backends/mizan-django/src/mizan/client`)
|
||||||
|
|
||||||
|
**Charter.** The Django HTTP/RPC dispatch surface: the executor that validates input, enforces auth, runs the function, and branches RPC-vs-view; the invalidation and merge resolvers; the context-bundle fetch; JWT/MWT request authentication. It owns per-request Django dispatch semantics; it does not own the registry, the IR, or the cache implementation (it calls them).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- RPC call dispatch returning `{result, invalidate}` and `merge` (README.md; MIZAN.md §4).
|
||||||
|
- Named-context bundle fetch — one GET returns all functions in the context, never N round-trips (INVARIANTS.md § Named Contexts; MIZAN.md §3).
|
||||||
|
- Mutation invalidation with three-tier auto-scoping; on failure nothing invalidates; developer writes no cache key (INVARIANTS.md § Mutation Invalidation).
|
||||||
|
- Auth enforced at dispatch, rejecting before the body runs, identically across transports (INVARIANTS.md § Auth; MWT_SPEC.md § Usage rule).
|
||||||
|
- Both invalidation transports: JSON body and `X-Mizan-Invalidate` header (ROADMAP.md § Done).
|
||||||
|
- Return-type branching: data → RPC path, `HttpResponse` → view path (ROADMAP.md § Done).
|
||||||
|
- Origin-side HMAC cache read/write on context fetch; `cache=False`/`rev` policy (docs/CACHE_KEYING.md; docs/PRODUCT_ARCHITECTURE.md § Spec additions).
|
||||||
|
- MWT/JWT server-side auth enforcement in the executor (`_check_auth_requirement`) (docs/MWT_SPEC.md § Usage rule).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
|
||||||
|
Dispatch & validation:
|
||||||
|
- `execute_function` validates input against the function's Pydantic `Input` before invoking the body, and rejects private functions from RPC — observable: a missing required field returns `VALIDATION_ERROR` with per-field detail and the body never runs; a `private=True` function returns `FORBIDDEN` when called over `/call/`.
|
||||||
|
- output serialization walks `BaseModel`/`list`/`dict` recursively via `to_jsonable_python` so `list[BaseModel]` reaches the wire as a bare array — observable: a `-> list[Item]` function returns `[{…},{…}]`, not `{"result":[…]}`; an `Optional[Model]` returning `None` serializes to `null` not `{"result":null}`.
|
||||||
|
|
||||||
|
Named-context bundle fetch (single request, param-filtered):
|
||||||
|
- `execute_context` runs every function in the group in one request, passing each only the params it declares, and fails the whole bundle if any member fails auth/validation — observable: `GET /ctx/user/?user_id=5&page=3` returns `{user_profile:…, user_orders:…}` where `user_profile` never sees `page`; if one member requires auth and the request is anonymous, the whole fetch returns the auth error, not a partial bundle.
|
||||||
|
|
||||||
|
Three-tier invalidation (the invariant that separates the AFI from typed RPC):
|
||||||
|
- `_resolve_invalidation` auto-scopes by matching mutation args against context param names (Tier 1), falling back to the bare context (Tier 3), and resolves function-level `affects` to the function name — observable: `update_profile(user_id=5,…)` against a `user` context emits `[{context:"user", params:{user_id:5}}]`; a mutation whose args don't overlap emits `["user"]`; `affects="user_profile"` emits the function name as the key.
|
||||||
|
- invalidation is emitted on both transports and only on success — observable: a successful mutation carries both `response["invalidate"]` (JSON body) and `X-Mizan-Invalidate: user;user_id=5` (header, URL-encoded so `q=hello world`→`q=hello%20world` and semicolons survive a parse round-trip); a mutation that raises emits neither.
|
||||||
|
- `_resolve_merges` resolves the merge slot server-side by matching the mutation's Output type against context members' Output types (`types_match_for_merge`), emitting `{context, slot, value, params?}` only on a unique match — observable: with `morph_groups: list[MorphGroupMeta]` and `morph_layers: list[MorphLayer]` in one context, a mutation returning `MorphLayer` merges into `morph_layers` only; the kernel does no shape inference.
|
||||||
|
|
||||||
|
Auth enforced before the body:
|
||||||
|
- `_check_auth_requirement` runs before `view.call`, handling `required`/`staff`/`superuser`/callable and mapping to `UNAUTHORIZED`/`FORBIDDEN` — observable: an anonymous call to `@client(auth=True)` returns `UNAUTHORIZED` and the function body never executes; a callable raising `PermissionError` surfaces its message as `FORBIDDEN`.
|
||||||
|
- MWT is checked first (`X-Mizan-Token`), then legacy JWT (`Authorization: Bearer`), then session+CSRF; a present-but-invalid token is rejected (never a silent fall-through to session) — observable: an invalid `X-Mizan-Token` returns 401 without trying session auth; a valid MWT sets `request.user = MWTUser` with no DB query; CSRF is enforced only on the session path.
|
||||||
|
|
||||||
|
Return-type branching + origin cache:
|
||||||
|
- a function returning an `HttpResponse` takes the view path (invalidation rides the header, `Cache-Control: no-store`), while a data return takes the RPC path — observable: a `-> HttpResponseRedirect` mutation returns the 302 with `X-Mizan-Invalidate` set; the same-decorated `-> Shape` mutation returns JSON with `invalidate` in the body.
|
||||||
|
- context fetch consults the origin cache keyed by the effective `rev` (max across members) and effective cache policy (`False` short-circuits), stores deterministic (sorted-key) JSON on miss, and purges scoped/broad on mutation — observable: two identical fetches return byte-identical bodies and the second carries `X-Mizan-Cache: HIT`; a scoped mutation for `user_id=5` purges only that entry and leaves `user_id=6` a HIT; a context with any `cache=False` member emits `no-store`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django cache (`backends/mizan-django/src/mizan/cache`)
|
||||||
|
|
||||||
|
**Charter.** The Django-side origin cache facade over `mizan_core`'s backends and key derivation — the free, unit-testable local cache that implements the same HMAC key and purge semantics as the paid Edge. It owns cache lifecycle/config resolution and the scoped-vs-broad purge dispatch; it does not own key derivation (delegates to core).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Free framework origin-side cache implementing the full cache protocol locally, same HMAC key and purge as Edge (docs/PRODUCT_ARCHITECTURE.md § Free framework; docs/CACHE_KEYING.md § Cache architecture).
|
||||||
|
- Scoped purge recomputes the key and deletes directly; broad purge SCANs the `ctx:{context}:*` prefix (docs/CACHE_KEYING.md § Required operations; backends/mizan-django/src/mizan/cache/KNOWN_ISSUES.md).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `cache_purge` recomputes the exact HMAC key for a scoped purge (one DELETE) and prefix-scans for a broad purge, so scoped invalidation touches exactly one entry — observable: `cache_purge(ctx, {user_id:5}, secret)` deletes only user 5's entry (returns 1) and leaves user 6; `cache_purge(ctx)` with no params removes every entry under the prefix.
|
||||||
|
- cache enablement is gated on both `cache_secret` and `cache_redis_url` present, thread-safe and lazily initialized — observable: with only one configured, caching is disabled and logged; concurrent `get_cache()` calls initialize once.
|
||||||
|
- OWED (open, per KNOWN_ISSUES.md): purge atomicity (index read/delete race), cross-language stringification for all value types (not just bool/None), per-param sub-index cleanup on broad purge, thundering-herd/single-flight protection, `cache_get`/`cache_put` argument-shape consistency, and RedisCache test coverage — each is a named correctness/operability obligation the "same protocol as Edge, security-critical" claim rests on and that the source has flagged as not-yet-satisfied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django channels (`backends/mizan-django/src/mizan/channels`)
|
||||||
|
|
||||||
|
**Charter.** The WebSocket transport: the `ReactChannel` base + registry, the multiplexed consumer that handles channel subscribe/message and RPC-over-WS, server push, and channel schema export. It owns real-time bidirectional messaging and WS-transported RPC; it does not own HTTP dispatch (reuses the executor).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- WebSocket support: `websocket=` dispatched over a persistent connection; server-initiated messages reach subscribed contexts; declaration and wire semantics uniform across adapters (INVARIANTS.md § WebSocket Support).
|
||||||
|
- WebSocket channels — typed bidirectional communication, real-time (ROADMAP.md § Done).
|
||||||
|
- Channels compose into the IR channel section (docs/AFI_ARCHITECTURE.md — codegen channels target consumes the channel nodes).
|
||||||
|
- Auth/authorization checked before any channel or RPC body runs (INVARIANTS.md § Auth; consumer security).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the consumer multiplexes many channel subscriptions and RPC calls over one socket, keyed by `(channel, params_json)`, and validates Pydantic params/messages before `authorize`/`receive` — observable: subscribing with a wrong-typed param returns an error before authorization; a duplicate subscription to the same `(channel, params)` is rejected; unsubscribe leaves zero lingering subscriptions after rapid subscribe/unsubscribe cycles.
|
||||||
|
- WS-RPC only dispatches functions explicitly marked `websocket=True`, running the same `execute_function` (so validation/auth are identical to HTTP) — observable: an RPC call to an HTTP-only function returns `FORBIDDEN` ("use POST /call/"); a WS call to a `websocket=True` fn returns the same envelope shape as HTTP; a missing `id`/`fn` returns a structured error.
|
||||||
|
- `authorize()` gates every subscription and exceptions in it are contained — observable: `authorize` returning `False` blocks the subscribe with "Not authorized"; an `authorize` that raises returns an error rather than crashing the socket; room-level authorization enforces per-param access (room 1 allowed, room 999 rejected).
|
||||||
|
- server push (`push`/`ReactChannel.push`) broadcasts to the channel-layer group, converting Pydantic to JSON, so a server function can reach subscribers — observable: `ChatChannel.push(room="general", message=…)` sends to `chat_general` with the message body; push with no channel layer configured warns rather than raising.
|
||||||
|
- channel schema is exported into the registry's `channels` extension carrying params/react/django message shapes and a `bidirectional` flag — observable: a channel with a `ReactMessage` reports `bidirectional: true`; a push-only channel reports `false` and omits `react_message`, and the codegen channels target emits the matching typed envelopes and `useXChannel` hook.
|
||||||
|
- JWT auth over the WS handshake authenticates from the `?token=` query param without a DB query, taking precedence over session — observable: a valid access token sets `scope["user"]` to a `JWTUser` from claims; an invalid token falls back to session rather than rejecting the socket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django forms (`backends/mizan-django/src/mizan/forms`)
|
||||||
|
|
||||||
|
**Charter.** The Forms composition: `mizanFormMixin`/`mizanFormMeta` turning a Django Form into the three role-tagged server functions (schema/validate/submit), plus formsets, and the field schema/validation projection. It owns Django-Form-to-server-function translation; it does not own generic RPC dispatch. Auth-provider (django-allauth) forms are **out of scope** — the docs place them in a dedicated external `mizan-allauth` repository built on this mixin; this unit owes only the primitive they build on.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Forms are three role-tagged client functions (schema / validate / submit) plus field validation, composed from RPC + validation (INVARIANTS.md § Compositions — Forms).
|
||||||
|
- Forms (schema/validate/submit) and formsets as Django stack extensions (ROADMAP.md § Done).
|
||||||
|
- Auto-registers `{name}.schema` / `.validate` / `.submit`; frontend gets `useXForm()` (backends/mizan-django/README.md § Forms).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `mizanFormMixin.__init_subclass__` auto-registers exactly three role-tagged server functions per concrete form (and formset variants when enabled), carrying `form`/`form_name`/`form_role` meta — observable: defining a `ContactForm` with a `mizanFormMeta(name="contact")` registers `contact.schema`, `contact.validate`, `contact.submit`; a form without a `mizan` attribute registers nothing; enabling `enable_formset` adds `contact.formset.{schema,validate,submit}`.
|
||||||
|
- the schema function projects each Django field into a typed `FieldSchema` (mapping field classes to Python types, extracting choices from `ModelChoiceField` safely, serializing initial values) and carries the `mizanFormMeta` display/behavior settings — observable: a `CharField`/`EmailField`/`Textarea` form yields three typed fields with correct `type`/`widget`; a `ModelChoiceField` yields JSON-serializable `{value,label}` choices (no `ModelChoiceIteratorValue` leak).
|
||||||
|
- validate runs the real Django form validation and returns structured per-field errors; submit branches multipart-vs-JSON, calls the form's `on_submit_success`/`on_submit_failure`, and returns pass/fail with data — observable: submitting an invalid email returns field errors and `success: false`; a valid submit runs `on_submit_success` and returns its data; a multipart submit binds files.
|
||||||
|
- `create_form_instance` threads `request`/`user`/`instance` init kwargs into the Django form and gracefully drops any the form doesn't accept, so the mixin is a reusable primitive for forms that need request context (the base the external `mizan-allauth` repo builds on) — observable: a form declaring a `request` kwarg receives it; a form that doesn't accept `request` still instantiates rather than raising `TypeError`.
|
||||||
|
- OWED (open, ISSUES.md § Open / ROADMAP.md § Next): a forms codegen target wired to `mizanCall` from the kernel, retiring the hand-written `mizan-react/src/forms.ts` — observable when built: the codegen emits form clients against the kernel; today no form codegen target exists, so the frontend form surface still depends on the pre-kernel provider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django shapes (`backends/mizan-django/src/mizan/shapes`)
|
||||||
|
|
||||||
|
**Charter.** The "API Shapes" primitive: Pydantic-typed queryset projection over django-readers, PK-keyed structural diffing (add/modify/delete) across nested relations. It owns ORM projection and diff derivation; it does not own dispatch.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- API Shapes to the fullest extent: ORM integration, auto-diffing by primary key (add/modify/delete, Django as reference), authorable near the used function (INVARIANTS.md § API Shapes).
|
||||||
|
- Shapes — Pydantic + django-readers for typed query projections (ROADMAP.md § Done).
|
||||||
|
- Context classes send/receive with Shape diffing (INVARIANTS.md § Compositions — Context classes; MIZAN.md §5).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `Shape.query` compiles a django-readers projection from the Pydantic field set + nested Shapes, executing minimal queries (single query for flat, prefetch for nested) and validating each row — observable: a flat shape query runs one SQL query; a nested `AuthorCardShape` with `books` runs two (prefetch), not N+1; per-relation querysets filter nested rows (`books=lambda qs: qs.filter(is_published=True)`).
|
||||||
|
- diffing computes add/modify/delete by primary key across nested relations, using a single batched query for existing rows and strict access to nested diffs — observable: `diff_many` of mixed new+existing items runs one query for the existing set; a nested diff reports `created`/`updated`/`deleted` by child PK; accessing a mistyped nested name raises (KeyError/AttributeError) rather than silently returning empty.
|
||||||
|
- PK/type resolution handles integer, slug, and UUID primary keys, two FKs to the same model, self-referential and nullable FKs, and treats `False`/`0`/`""` as present values — observable: a UUID-PK `Section` shape diffs correctly; `is_published=False` is not treated as missing; a nullable editor FK returns `None` rather than erroring.
|
||||||
|
- OWED (unbuilt): the `ReactContext('name')` class form with `send`/`receive` and a `POST /ctx/<name>/commit/` endpoint that routes committed shape data to `receive`, with auto-refetch-or-fresh-return after commit (INVARIANTS.md § Compositions; MIZAN.md §5) — observable when built: a class defining `send`/`receive` generates a read hook and a commit function; committing runs `receive` and either refetches or uses a returned Shape. Today `ReactContext` is only a context-name marker with no metaclass, `send`/`receive`, or commit endpoint — the class form is unsubstantiated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django SSR (`backends/mizan-django/src/mizan/ssr`)
|
||||||
|
|
||||||
|
**Charter.** The SSR product's Django half: a template backend that renders `.tsx`/`.jsx` component files through a persistent Bun subprocess, wrapping output with a hydration payload. It owns the Django-template-engine integration and the Bun subprocess lifecycle; it does not own the JS render (that is the Bun worker).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- SSR is a Django template backend replacing the rendering engine; the template name IS a `.tsx`/`.jsx` file path; context dict becomes props; output wrapped in `<div id="mizan-root">` + `window.__MIZAN_SSR_DATA__` hydration (docs/SSR_ARCHITECTURE.md).
|
||||||
|
- SSR bridge: Django template backend → persistent Bun subprocess via JSON-RPC; worker resolves by file path (`import(file)` + `renderToString`); auto-restarts on crash, thread-safe, correlates by message id (ROADMAP.md § Done; docs/SSR_ARCHITECTURE.md § Implementation surface).
|
||||||
|
- SSR is orthogonal to RPC and composable; first paint carries data (INVARIANTS.md § SSR).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `MizanTemplates` implements Django's template-backend interface: `get_template(name)` resolves `name` as a file path under `DIRS` and returns a `MizanTemplate` wrapping the absolute path; `render` strips `request`/`csrf_token` and passes the remaining context as props — observable: `render(request, 'components/Hello.tsx', ctx)` renders that file's component with `ctx` as props; `from_string` raises (it renders files, not strings); a missing file raises `TemplateDoesNotExist`.
|
||||||
|
- rendered output is wrapped for client hydration — observable: output contains `<div id="mizan-root">…</div>` plus `<script>window.__MIZAN_SSR_DATA__={sorted-json}</script>`, so first paint carries the props the client hydrates from.
|
||||||
|
- `SSRBridge` holds one persistent `bun run <worker>` subprocess, correlates requests by message id over newline-delimited JSON-RPC, serializes stdin writes, is thread-safe under concurrent renders, waits for a ready signal on start, and auto-restarts on crash — observable: five concurrent renders return five correct results with no interleaving; killing the subprocess mid-life and rendering again transparently restarts it; a render exceeding the timeout raises `TimeoutError` rather than hanging.
|
||||||
|
- OWED (partial, docs/PSR_VS_EDGE.md § Current state): the render-on-mutation orchestration (mutation → trigger local render → store HTML), driven by the manifest's `render_strategy`, wiring the bridge to the PSR path — observable when built: a public-context mutation triggers a local re-render and stores HTML; today the bridge renders on request and the manifest records the strategy, but the mutation→render→store wiring is absent, so PSR-on-mutation is unsubstantiated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django JWT/MWT (`backends/mizan-django/src/mizan/jwt`)
|
||||||
|
|
||||||
|
**Charter.** The Django identity layer: JWT access/refresh tokens tied to sessions, the MWT-mint server functions, JWT settings/algorithm resolution, and the Ninja security class. It owns Django-session-bound token issuance and validation; the MWT format itself lives in `mizan_core.mwt`.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- JWT auth (access/refresh, session validation) auto-detected, CSRF handled (ROADMAP.md § Done).
|
||||||
|
- MWT is issued from an authenticated identity; `create_mwt(user, secret, ttl, audience, kid)`; a separate JWT module still exists for user-auth tokens (docs/MWT_SPEC.md § Key decisions).
|
||||||
|
- MWT is the cache-keying identity, not a replacement for JWT auth (docs/MWT_SPEC.md).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- JWT tokens carry `sub`/`sid`/`staff`/`super`/`type`/`iat`/`exp` and are tied to a session key so logout revokes them — observable: a refresh whose underlying session was destroyed returns `None` (immediate revocation); an access `JWTUser` is built from claims with no DB query; `decode_token` enforces the expected token type.
|
||||||
|
- settings auto-detect algorithm from key shape (PEM→RS256 else HS256) and derive the public key from the private RSA key when absent — observable: an HS256 secret works with `public_key == private_key`; a PEM private key auto-selects RS256 and extracts the public key.
|
||||||
|
- `mwt_obtain` mints an MWT from the authenticated session via `create_mwt`, requiring `MIZAN_MWT_SECRET`, and `jwt_obtain`/`jwt_refresh` issue/rotate the JWT pair carrying user claims — observable: `mwt_obtain` on an anonymous request raises; with no secret configured it raises a clear config error; the JWT pair includes `is_staff`/`is_superuser` so downstream auth needs no DB query.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django registration & export (`backends/mizan-django/src/mizan/export`, `.../management`, `.../setup`, `.../__init__.py`, `.../urls.py`, `.../_vendor`)
|
||||||
|
|
||||||
|
**Charter.** The Django discovery/registration glue and the two protocol export surfaces: the Edge manifest generator and the KDL IR management command, plus URL wiring, session-init, and the ASGI/channels wrapper. It owns clients.py auto-discovery and the manifest/IR export commands; it delegates registry and IR shape to `mizan_core`.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Codegen IR export (KDL) via `python manage.py export_mizan_ir` (backends/mizan-django/README.md; docs/AFI_ARCHITECTURE.md § Forward-direction primitives).
|
||||||
|
- Edge manifest export, deterministic (sorted) output; both RPC and view-path functions; records each context's `render_strategy` (ROADMAP.md § Done; docs/PSR_VS_EDGE.md; ISSUES.md § Resolved — edge manifest non-determinism fixed).
|
||||||
|
- Function discovery / registration via the clients.py convention (backends/mizan-django/README.md § Setup; MIZAN.md §6).
|
||||||
|
- PSR (`render_strategy` in manifest) (docs/PSR_VS_EDGE.md).
|
||||||
|
- Session / CSRF init endpoint; `wrap_asgi` WebSocket routing (backends/mizan-django/README.md § Setup).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `export_mizan_ir` populates the registry via discovery, then writes canonical KDL from `mizan_core.ir.build_ir` — observable: the Django-emitted KDL is byte-identical to the FastAPI and Rust emissions for the same fixture (`tests/afi/test_codegen_parity.py`); this is the "IR is the only contract, validated against multiple adapters" claim made checkable.
|
||||||
|
- `generate_edge_manifest` emits a deterministic (sorted contexts and mutations) JSON mapping contexts to endpoints/params/functions, distinguishing rpc vs view path, marking `user_scoped` and `render_strategy` (`dynamic_cached` for user-scoped, `psr` for public), and mutations with auto-scoped params + private/route — observable: two exports are byte-identical regardless of registration order; a context with `user_id` is `user_scoped`+`dynamic_cached`; a view-path function's `route` populates `page_routes`; a mutation whose args match context params lists them under `auto_scoped_params`.
|
||||||
|
- `mizan_clients` discovers `ServerFunction` subclasses under each app's `clients.py`/`clients/` layer and registers them idempotently — observable: re-running discovery does not double-register; a class already registered under a different name is skipped rather than clobbered.
|
||||||
|
- the session-init view sets the CSRF cookie and returns the token, and `wrap_asgi` routes `/ws/` to the channels consumer — observable: `GET /session/` returns `{csrfToken}` and a `Set-Cookie: csrftoken=…`, so SSR/clients can establish CSRF before an authenticated call; `wrap_asgi(get_asgi_application())` produces a ProtocolTypeRouter dispatching http vs websocket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django protocol tests (`backends/mizan-django/src/mizan/tests` — test_core.py, test_auth.py, test_ssr.py, test_benchmarks.py)
|
||||||
|
|
||||||
|
**Charter.** The Django backend's protocol-and-integration verification: the executor/registry/invalidation/merge/cache/manifest/edge-compatibility/auth/SSR/throughput suites. It holds the evidence that the dispatch, invalidation, cache, auth, and SSR mechanisms above behave as claimed against the real HTTP stack; it authors no production mechanism.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- The dispatch, invalidation, merge, cache, auth, and SSR claims of the mizan-django dispatch/cache/ssr/jwt sub-units are *verified* here; the "passes its own test suites" status rests on this harness.
|
||||||
|
- Edge caching is provable before Edge exists — deterministic JSON, correct Cache-Control, header round-trip, auth-differentiated responses (test_core.py § EdgeCompatibilityTests, an explicit doc-shaped claim carried in the suite).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the suite exercises the real HTTP stack (Django test client / LiveServer) — not just RequestFactory — for dispatch, three-tier invalidation, merge, view-path branching, and cache HIT/MISS/scoped-purge — observable: `HTTPIntegrationTests` and `CacheIntegrationTests` assert the JSON body and `X-Mizan-Invalidate` header agree, that a scoped mutation preserves other users' cached entries, and that a second identical fetch is a HIT.
|
||||||
|
- the auth suite covers every axis (JWT valid/invalid/expired, MWT, session, staff/superuser/callable/PermissionError) and asserts the body never runs on failure — observable: an invalid token returns 401 without session fall-through; an anonymous call to an auth-required function returns before the body; a callable's `PermissionError` message surfaces verbatim.
|
||||||
|
- the Edge-compatibility suite asserts the properties a CDN cares about (deterministic byte-identical bodies, sorted JSON keys, URL-encoded delimiter-safe headers, `no-store` on errors/mutations, header↔body invalidation agreement, auth-differentiated responses for the same URL) — observable: these tests go red if any of those properties regress, so "Edge caching is possible" is checkable without a CDN.
|
||||||
|
- the SSR suite verifies the bridge and template backend end-to-end when Bun is present (ping, render, missing-component error, crash recovery, concurrent renders, hydration wrapper) and skips gracefully otherwise — observable: a killed worker transparently restarts on the next render; five concurrent renders return five correct results.
|
||||||
|
- the benchmark suite measures HTTP-vs-executor overhead and throughput with correctness assertions on each path — observable: every benchmark also asserts the function's numeric output, so a green benchmark run is also a correctness run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-django adversarial & feature tests (`backends/mizan-django/src/mizan/tests` — test_pentest.py, test_security.py, test_channels.py, test_shapes.py)
|
||||||
|
|
||||||
|
**Charter.** The Django backend's adversarial and feature-specific verification: the penetration/security suites (attacker-shaped defenses) and the channels/shapes suites (feature behavior). It holds the evidence that validation, authorization, channel subscription, and shape diffing behave as claimed against hostile and edge inputs; it authors no production mechanism.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- The auth-guard, input-validation, and no-info-disclosure claims (INVARIANTS.md § Auth; executor validation) are verified adversarially here.
|
||||||
|
- The WebSocket channel authorization/subscription and API Shapes diff/query claims (INVARIANTS.md § WebSocket Support, § API Shapes) are verified here.
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the pentest and security suites assert the properties an attacker probes: validation-runs-before-execution, private/internal functions unreachable over RPC, no sensitive detail in production error messages, injection strings (SQL/command/template/prototype-pollution/unicode-lookalike/zero-width) treated as inert data, and no function-existence timing leak — observable: these tests go red if the executor ever runs a body before validation, leaks a secret in a 500, or executes an injection payload.
|
||||||
|
- the channels suite verifies subscription lifecycle and authorization: param validation before `authorize`, `authorize`-false and `authorize`-raise both blocking cleanly, duplicate-subscription rejection, room-level per-param authorization, and WS-RPC gated to `websocket=True` functions — observable: subscribing to a room the user cannot access is rejected; an RPC to an HTTP-only function returns FORBIDDEN over the socket.
|
||||||
|
- the shapes suite verifies query efficiency and diff correctness across the hard cases: single-query flat, prefetch nested (no N+1), UUID/slug/int PKs, two-FKs-to-same-model, self-referential and nullable FKs, `False`/`0`/`""` treated as present, batched `diff_many`, and strict nested-diff access raising on typos — observable: a nested query asserts exactly the prefetch count; a mistyped nested-diff name raises rather than silently returning empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-fastapi (`backends/mizan-fastapi/src/mizan_fastapi`)
|
||||||
|
|
||||||
|
**Charter.** The FastAPI adapter targeting the AFI-common subset: RPC dispatch, context bundling, JSON-body invalidation + merge, auth gating, the error envelope, and the KDL IR CLI. It owns the FastAPI transport surface over `mizan_core`; Forms/Channels/Shapes/SSR are explicitly out of scope.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- RPC call dispatch, named-context bundle fetch, JSON-body invalidation, three-tier auto-scoping, function registration, KDL IR export (README.md § Adapters; backends/mizan-fastapi/README.md).
|
||||||
|
- Auth-guard enforcement (`auth=` rejects) (backends/mizan-fastapi/README.md § Auth integration).
|
||||||
|
- The same core primitives as Django, proving the protocol is not Django-specific; IR-shape parity with Django and Rust (README.md § Conformance; docs/AFI_ARCHITECTURE.md).
|
||||||
|
- Every error path renders through the Mizan envelope; `GET /session/` returns a null CSRF token for wire parity (backends/mizan-fastapi/README.md § Setup; README.md § Adapters note 7).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `execute_function` looks up the registered function, enforces `auth` before running (matching Django's semantics: `True`/`required`/`staff`/`superuser`/callable), validates input against the Pydantic `Input`, awaits `view.acall` (async handlers on the loop, sync in a threadpool), and serializes via `jsonable_encoder` — observable: an anonymous call to `@client(auth=True)` returns 401 before the body; an `async def` handler runs on the loop (a real `await` inside completes); `list[BaseModel]`/`Optional[BaseModel]` reach the wire bare.
|
||||||
|
- `compute_invalidation` auto-scopes by matching args against the context's declared Input fields, emitting a bare context or a `{context, params}` object — observable: a mutation with a matching arg emits the scoped form, a non-matching arg the bare context string; identical to the Django resolver's output.
|
||||||
|
- `compute_merges` resolves the slot by unique return-type match (`types_match_for_merge`) and emits `{context, slot, value, params?}`, dropping ambiguous — observable: the `morph_groups`/`morph_layers` fixture routes a `MorphLayer` mutation to `morph_layers` only; a merge-only mutation emits `merge` with empty `invalidate`.
|
||||||
|
- the router exposes `POST /call/`, `GET /ctx/{name}/`, `GET /session/` and both exception handlers render every failure through `{"error":{code,message,details?}}` with `Cache-Control: no-store` — observable: an unknown function returns 404 in the envelope; a malformed body returns `BAD_REQUEST`; a validation failure returns 422; `/session/` returns `{csrfToken: null}` (parity, since CSRF is Django-only).
|
||||||
|
- `python -m mizan_fastapi.ir <module>` imports the module (triggering registration) and writes canonical KDL — observable: its output equals the Django management command's output for the same fixture (three-way parity).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust-axum (`backends/mizan-rust-axum`)
|
||||||
|
|
||||||
|
**Charter.** The Rust/Axum HTTP adapter: the `/call/`, `/ctx/:name/`, `/session/` handlers, the error envelope, and app-state threading, dispatching through `mizan-core`'s `FUNCTIONS` registry. It owns the Axum wire surface; dispatch/invalidation/merge logic is `mizan-core`.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- RPC call dispatch, named-context bundle fetch, JSON-body invalidation, three-tier auto-scoping, KDL IR export (README.md § Adapters; note 6).
|
||||||
|
- Axum error envelope mirrors FastAPI's with `Cache-Control: no-store` (backends/mizan-rust-axum/src/errors.rs).
|
||||||
|
- Query params are coerced to typed JSON via the per-function input params (handlers.rs).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `function_call` dispatches through `lookup_function` + `FunctionSpec::dispatch`, then attaches `compute_invalidation` and `compute_merges` output, mirroring the FastAPI response shape `{result, invalidate, merge?}` — observable: the wire-parity drivers (`tests/rust/drive_kernel.rs`, `drive_emitted.rs`) run the same probes against the Axum server and FastAPI and require the same JSON shapes and invalidate/merge semantics.
|
||||||
|
- `context_fetch` bundles every registered member of the context and coerces string query params to typed JSON via each function's `input_params` primitive table — observable: `GET /ctx/user/?user_id=5` returns the flat bundle with `user_id` coerced to an integer before dispatch; an unknown context returns the envelope 404.
|
||||||
|
- app state is type-erased into the handle and downcast in user functions — observable: a handler downcasts `RequestHandle` to the concrete state type; the stateless router variant threads a unit handle.
|
||||||
|
- OWED (unbuilt caveats, README.md § Caveat + notes 2,3,5): Axum declares `Transport::Websocket` in the IR/macro but routes no WebSocket handler; carries `is_form`/`form_role` trait stubs but no validate/submit endpoint; and accepts `auth=` on a function but the dispatch path does not enforce it — observable: a `websocket=True` function is reachable only over HTTP; an `auth=True` function is NOT rejected for anonymous callers on this adapter. These are documented gaps the "auth enforced on every adapter" invariant (INVARIANTS.md § Auth) owes and Rust/Axum does not yet meet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-tauri (`backends/mizan-tauri`)
|
||||||
|
|
||||||
|
**Charter.** The Tauri adapter: a plugin exposing a single `mizan_invoke` command that routes op-tagged call/fetch envelopes through the shared `mizan-core` registry over Tauri IPC. It owns the IPC wire surface; dispatch/invalidation/merge are `mizan-core`.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- RPC call dispatch, named-context bundle fetch, invalidation (JSON body only), three-tier auto-scoping (README.md § Adapters; note 1).
|
||||||
|
- Transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP; invalidation rides the response body; no header channel (README.md note 1; backends/mizan-tauri/README.md § Wire protocol).
|
||||||
|
- `RequestHandle` wraps `AppHandle` so functions can access managed state; `Result<T, MizanError>` supported (backends/mizan-tauri/README.md § App-state access).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the plugin registers exactly one command (`plugin:mizan|mizan_invoke`) that deserializes the op-tagged envelope and dispatches `call`/`fetch` through the same `FUNCTIONS`/`CONTEXTS` slices the HTTP adapter uses — observable: `{op:"call", fn, args}` returns `{result, invalidate, merge?}` and `{op:"fetch", context, params}` returns the flat bundle, identical shapes to the axum adapter minus the header channel; there is no per-function `#[tauri::command]`.
|
||||||
|
- errors flow through Tauri's reject path re-wrapped into the `{code, message, details?}` shape — observable: a `MizanError::ValidationFailed` reaches the JS transport as the same envelope an HTTP 422 would carry, so consumer error handling is transport-agnostic.
|
||||||
|
- `RequestHandle::new(app)` lets a function downcast to `tauri::AppHandle` for managed state / event emission — observable: a function calling `req.downcast::<tauri::AppHandle>()` reaches Tauri state; stateless functions ignore the handle.
|
||||||
|
- OWED (unbuilt caveat, README.md § Caveat + note 5): Tauri's `FunctionSpec` carries `auth`/`private` fields but the dispatch path does not enforce them — observable: an `auth=`-declared function is not rejected for an unauthorized caller on this adapter; the "auth enforced on every adapter" invariant is not yet met here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-rust client kernel (`frontends/mizan-rust`)
|
||||||
|
|
||||||
|
**Charter.** The Rust port of the shared client kernel: the reconciled cache (context registry + state), transport (HTTP with retry, CSRF), merge splicing, the debounced invalidation queue, error-envelope parsing, and the PyO3 bridge that exposes the kernel to Python. It owns the client-side reconciled view; framework rendering lives in adapters.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- The client kernel owns the reconciled cache — context state, status, error, server-driven merge and invalidate, session init — reached through a pluggable transport; no adapter keeps its own copy of the truth (INVARIANTS.md § Client Kernel; docs/AFI_ARCHITECTURE.md § Kernel model).
|
||||||
|
- Mutation invalidation auto-refetches affected contexts; on failure nothing invalidates (INVARIANTS.md § Mutation Invalidation).
|
||||||
|
- Merge splices the return value into the cached entry rather than refetching (the `merge=` path; MIZAN.md §5 fresh-return optimization generalized).
|
||||||
|
- Transports are pluggable (HTTP, Tauri IPC, webview) via `configure` (docs/AFI_ARCHITECTURE.md § Kernel model; frontends/mizan-tauri-transport/README.md).
|
||||||
|
- The Python client is a typed facade over this kernel via PyO3 (protocol/mizan-codegen python target; baselines/python/client.py).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- the context registry keys entries by context name + `stable_key(params)`, holds one `ContextState {data, status, error}` per entry, and notifies subscribers via a watch channel that coalesces to the latest state — observable: `stable_key({b,a})` == `stable_key({a,b})` (byte-identical to `JSON.stringify` with sorted keys), so the same params hit the same cache entry regardless of key order; a refetch advances the entry through Loading→Success visible to subscribers.
|
||||||
|
- `mizan_call` applies the response's `merge` entries first, then queues `invalidate` entries, then returns `result` — observable: a mutation response `{result, merge, invalidate}` splices the merged slot into the cached bundle AND schedules refetch; a failed call (4xx) surfaces the error and invalidates nothing.
|
||||||
|
- `splice_slot` upserts by `id` into an array slot, replaces an array slot with a new array, replaces a scalar, and no-ops a merge into a slot absent from the bundle — observable: merging `{id:1,name:"A"}` into `[{id:1,…},{id:2,…}]` replaces entry 1 in place; merging into a missing slot leaves the bundle untouched (no fabricated slot on a stale cache).
|
||||||
|
- the invalidation queue debounces within one async tick, and broad invalidations subsume scoped ones for the same context — observable: two invalidations queued in the same tick flush once; a broad invalidate refetches every param variant while a scoped invalidate refetches only the matching entry.
|
||||||
|
- transport is HTTP-with-retry (3 attempts, linear backoff, retry on 5xx/network, surface 4xx immediately), reads the CSRF cookie into the configured header per call, and is swappable — observable: a 5xx retries then errors; a 4xx returns immediately; swapping the transport (Tauri/webview) leaves the generated call/fetch code unchanged (transport read from config).
|
||||||
|
- the error envelope parses both the FastAPI nested shape and the Django flat shape, falling back to `HTTP_<status>` — observable: `{"error":{"code":…}}` and `{"error":true,"code":…}` both yield the correct `code`; an unparseable body yields `HTTP_500` with the raw body.
|
||||||
|
- the PyO3 bridge exposes `call`/`fetch_context`/`subscribe_context`/`invalidate` with the GIL released across the network round-trip, and fires the Python subscription callback on each watch change with a `{data,status,error}` dict — observable: `py.allow_threads` wraps the blocking call; a subscription callback fires with `status: "success"` and the decoded data; cancelling ends the watcher.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-base and framework adapters (`frontends/mizan-base`, `frontends/mizan-react`, `frontends/mizan-vue`, `frontends/mizan-svelte`)
|
||||||
|
|
||||||
|
**Charter.** The TypeScript client kernel (`@mizan/base`) and the per-framework idiomatic adapters (React hooks, Vue composables, Svelte stores) that subscribe to it. `@mizan/base` is the authoritative kernel the `frontends/mizan-rust` unit ports; the TS source is referenced by the docs and adapters but is not inlined in this repo snapshot. Listed so the kernel claims and the adapter-parity claims are surfaced against their real roots. The `mizan-ts` cross-language HMAC pin (`deriveCacheKey`) also lives on the TS side.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Every frontend adapter is a thin idiomatic wrapper over one shared kernel; the kernel owns `ContextState<T> = {data,status,error}`, `registerContext`, `mizanCall`/`mizanFetch`, server-driven merge/invalidate, `initSession`, and a pluggable `MizanTransport` (HTTP default, Tauri/webview swap via `configure`) (INVARIANTS.md § Client Kernel; docs/AFI_ARCHITECTURE.md § Kernel model).
|
||||||
|
- Codegen targets the adapter surface, never the raw kernel; React devs get hooks, Vue composables, Svelte stores, same kernel underneath (docs/AFI_ARCHITECTURE.md § Kernel model).
|
||||||
|
- Vue and Svelte ship as v1 alongside React (docs/AFI_ARCHITECTURE.md § Launch surface).
|
||||||
|
- Cross-language HMAC pin: `deriveCacheKey` in `mizan-ts` matches the Python key byte-for-byte (docs/CACHE_KEYING.md; README.md § Adapters — TypeScript is the protocol-reference adapter).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `@mizan/base` owns the single reconciled view: `ContextState`, the context registry, `mizanCall`/`mizanFetch`, server-driven `merge`/`invalidate`, `initSession`, over a `MizanTransport` interface — observable: the same behaviors the `mizan-rust` port pins (stable-key cache identity, merge-splice, scoped-vs-broad refetch, retry, dual-envelope error parse) hold in TS; the Rust port exists precisely to mirror this file.
|
||||||
|
- `deriveCacheKey` (mizan-ts) reproduces the Python HMAC key byte-for-byte — observable: the pinned vectors in `cores/mizan-python/tests/test_keys.py::test_cross_language_pin` (`ctx:user:605a1ca5…`, `ctx:user:30fc08eb…`) are asserted against the TS output; any normalization drift (bool/None stringification, key ordering) breaks the pin, which the doc marks a security vulnerability.
|
||||||
|
- adapters subscribe to the kernel and render in their own idiom without keeping a parallel copy of the truth — observable: a React hook and a Vue composable over the same context read the same kernel entry; mutating in one path updates both because the truth lives once in the kernel.
|
||||||
|
- OWED (unbuilt, ISSUES.md § Open / ROADMAP.md § Next): `frontends/mizan-vue` and `frontends/mizan-svelte` are runtime kernel-adapter packages — the codegen emits their clients (byte-parity-tested) but no runtime package or live-backend example exists — observable when built: a Vue composable / Svelte store subscribes to `@mizan/base` and refreshes on invalidation against a live backend; today only React has full integration verification, so the "Vue and Svelte ship as v1" claim is unsubstantiated at the runtime layer.
|
||||||
|
- OWED (drift, ISSUES.md § Open): the Svelte codegen target emits Svelte 4 `readable` stores; Svelte 5 `$state`/`$derived` runes are owed — observable when built: the emitted Svelte client uses runes.
|
||||||
|
- OWED (migration, ISSUES.md § Open): `mizan-react/src/context.tsx` is the pre-kernel provider still shipped and imported by the desktop example, coexisting with the codegen-emitted kernel-subscribing `MizanContext`; retiring it (migrating the example onto the generated provider) is owed so the "every adapter wraps the kernel, none keeps its own truth" invariant holds without exception.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: mizan-codegen (`protocol/mizan-codegen`)
|
||||||
|
|
||||||
|
**Charter.** The single Rust codegen binary that reads KDL IR and emits typed clients for every target (stage1, react, vue, svelte, channels, python, rust), plus the source-fetching that spawns each backend's IR-export command and the Pydantic-pre-step. It owns the IR→client transform; it does not emit IR (backends do).
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Codegen reads KDL directly — no OpenAPI envelope, no `openapi-typescript`, no per-backend converter; the former JS two-stage codegen is deleted (docs/AFI_ARCHITECTURE.md § Forward-direction primitives).
|
||||||
|
- Every frontend client is generated from the IR; each target is byte-parity-tested (INVARIANTS.md § Canonical IR & Codegen; ROADMAP.md § Rust codegen).
|
||||||
|
- The codegen drives the backend's IR-export command as a subprocess and parses the KDL it writes (docs/AFI_ARCHITECTURE.md; backends/*/README.md § Generate the frontend).
|
||||||
|
- Pydantic + Rust DX: a decoru pre-step authors Rust types from Pydantic before the cargo IR bin runs; a generic `[source.script]` source spawns any command emitting KDL (backends/mizan-tauri/README.md § Pydantic; config.rs).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `fetch.rs` spawns the configured source's export command (FastAPI `-m mizan_fastapi.ir`, Django `manage.py export_mizan_ir`, Rust `cargo run --bin`, or a generic script) and parses stdout as KDL — no OpenAPI/converter anywhere in the path — observable: a codegen run against a live FastAPI backend consumes only the KDL the CLI writes; the Rust source runs the cargo bin and the optional decoru pre-step first.
|
||||||
|
- the KDL parser reconstructs the full typed IR (types with struct/list/enum/alias shapes, functions with input/output/nullable/context/affects/merge/form, contexts with param elevation, channels) — observable: `ir_deserialization.rs` reads the AFI fixture back into typed structs and asserts the function set, per-function fields, param elevation, and named-type presence.
|
||||||
|
- each target emits deterministically and is byte-parity-tested against a committed baseline — observable: `stage1_parity.rs`, `react_parity.rs`, `rust_parity.rs`, `python_parity.rs`, `vue_svelte_parity.rs`, and `channels_smoke.rs` diff emitter output against baselines and fail on any byte drift; two different runs produce identical output.
|
||||||
|
- the emitters produce genuinely different, correct artifacts per target — not one shape behind distinct labels — observable: the react target emits `<MizanContext>` + per-context providers + `use{Hook}()` reading React context; vue emits composables; svelte emits stores; python emits a Pydantic-typed facade over the PyO3 kernel; rust emits a full crate depending on `mizan-rust` — each byte-checked against its own baseline, and stage1 is auto-included whenever a framework target is requested.
|
||||||
|
- the codegen tree-shakes and canonicalizes types to match the backend emitters, and hoists inline enums into named Rust/TS types — observable: an unreferenced type is not emitted; an inline `field { enum … }` becomes a top-level Rust enum the struct field references; the channels target emits zero files when the IR carries no channels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: AFI conformance (`tests/afi`)
|
||||||
|
|
||||||
|
**Charter.** The cross-adapter conformance gate: one fixture registered identically in Django, FastAPI, and a Rust app, asserting all three emit byte-identical KDL. It is the executable form of "the IR is the only contract"; it authors no production mechanism.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- Adapter parity is gated by the AFI conformance suite asserting IR-shape parity — the same fixture through Django, FastAPI, and Rust emits byte-identical KDL (README.md § Conformance; docs/AFI_ARCHITECTURE.md § KDL is the IR — "divergence between adapters is what the IR exists to prevent").
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- one shared fixture (`fixture.py` and its Rust twin `rust_app`) registers the same 7 functions / 5 types / context+affects+merge graph across all three backends, and the parity test diffs the three KDL emissions requiring exact three-way equality — observable: `test_codegen_parity.py` fails (naming the divergent pair) the instant any adapter's type introspection, ordering, or param elevation drifts; the fixture spans the AFI axes (plain fn, no-input fn, shared-param context, affects mutation, optional return, merge mutation) so the gate is not a degenerate single-shape check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit: wire-parity drivers (`tests/rust`, `tests/rust/fixture_client`)
|
||||||
|
|
||||||
|
**Charter.** The runtime wire-contract gate: Rust drivers (`drive_kernel`, `drive_emitted`) that hit a live FastAPI fixture and a live Rust/Axum fixture and assert the same JSON shapes and invalidate/merge semantics, plus the codegen-emitted `fixture_client` crate they exercise. It proves the runtime wire equivalence the static IR parity does not, and authors no production mechanism.
|
||||||
|
|
||||||
|
**Claims substantiated here.**
|
||||||
|
- The Rust adapters honor the same wire contract as FastAPI beyond static IR equivalence — same JSON shapes, same invalidate/merge semantics (README.md § Adapters; the "IR prevents divergence" claim taken to the runtime).
|
||||||
|
- The codegen-emitted typed client round-trips cleanly through the kernel (protocol/mizan-codegen rust target).
|
||||||
|
|
||||||
|
**Owed behavioral mechanisms.**
|
||||||
|
- `run_wire_parity.py` boots each backend, probes the readiness surface `/api/mizan/session/` (Mizan-protocol-shaped, so the harness reads the same surface across backends), then runs both the raw-kernel and emitted-typed drivers against each, propagating any non-zero exit — observable: the drivers hit every fixture endpoint (plain functions, the two-function context, the optional-return path, the merge mutation) against both FastAPI and Rust/Axum and require the same responses; a wire drift on either backend turns the harness red.
|
||||||
|
- `drive_emitted` exercises the codegen-emitted `fixture_client` typed functions (`call_echo`, `fetch_user_context`, `call_update_profile`, the optional `call_find_user`, the merge `call_rename_user`) so the generated crate is proven to round-trip, not merely to compile — observable: `call_find_user(99999)` returns `None`, `fetch_user_context(5)` returns the bundled `user_profile`+`user_orders`, and any deserialization mismatch fails the driver.
|
||||||
127
README.md
127
README.md
@@ -20,99 +20,50 @@ def update_profile(request, user_id: int, name: str) -> dict:
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
Adapters exist for Django, FastAPI, Rust/Axum, Tauri, and TypeScript. Django is the
|
## Adapters
|
||||||
reference implementation; per-adapter support is inventoried below.
|
|
||||||
|
|
||||||
> **Status:** Mizan is not production-tested. It passes its own test suites but has not
|
Backends: Django (`backends/mizan-django`, the reference implementation), FastAPI
|
||||||
> been run in a production deployment. Treat it as pre-release.
|
(`backends/mizan-fastapi`), Rust/Axum (`backends/mizan-rust-axum`), Tauri
|
||||||
|
(`backends/mizan-tauri`), and TypeScript (`backends/mizan-ts`). Frontends are generated
|
||||||
|
from the KDL IR over the `@mizan/base` kernel; `frontends/` holds the kernel, the
|
||||||
|
per-framework adapters, and the transports.
|
||||||
|
|
||||||
|
Per-adapter transport shape:
|
||||||
|
|
||||||
|
- Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP.
|
||||||
|
Invalidation rides in the JSON response body; there is no header channel.
|
||||||
|
- Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme
|
||||||
|
registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin)
|
||||||
|
rather than fetching over HTTP.
|
||||||
|
- "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every
|
||||||
|
adapter carries typed input/output through the KDL IR; the projection primitive
|
||||||
|
itself is Django-only.
|
||||||
|
- FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
|
||||||
|
parity; CSRF is Django-only.
|
||||||
|
- TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
|
||||||
|
codegen source — it demonstrates the cache + invalidation protocol is
|
||||||
|
language-agnostic.
|
||||||
|
|
||||||
|
> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but their dispatch
|
||||||
|
> paths do not enforce it — do not rely on `auth=` for access control on those
|
||||||
|
> adapters.
|
||||||
|
|
||||||
|
Auth-provider integration (django-allauth) lives in its own repository,
|
||||||
|
`mizan-allauth` — a dedicated Django system built on mizan-django's forms and
|
||||||
|
context primitives.
|
||||||
|
|
||||||
|
## Conformance
|
||||||
|
|
||||||
|
Per-adapter capability support is measured by the AFI conformance suite in
|
||||||
|
[`tests/afi/`](tests/afi/), not maintained as prose — the suite asserts IR-shape
|
||||||
|
parity: the same fixture through Django, FastAPI, and the Rust adapter emits
|
||||||
|
byte-identical KDL (`test_codegen_parity.py`).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [`docs/`](docs/) — architecture references: AFI, SSR, cache keying, MWT, PSR vs. Edge
|
- [`docs/`](docs/) — architecture references: AFI, SSR, cache keying, MWT, PSR vs. Edge
|
||||||
- [`ROADMAP.md`](ROADMAP.md) · [`ISSUES.md`](ISSUES.md) — planned work and known gaps
|
- [`INVARIANTS.md`](INVARIANTS.md) — the AFI invariants every adapter satisfies
|
||||||
|
- [`ROADMAP.md`](ROADMAP.md) · [`ISSUES.md`](ISSUES.md)
|
||||||
## Backend adapters
|
|
||||||
|
|
||||||
Every adapter implements the same AFI wire protocol. The matrix below inventories
|
|
||||||
support per adapter, grouped to separate protocol guarantees from Django-specific
|
|
||||||
features (forms, ORM projection, auth providers, SSR). A cell counts as supported only
|
|
||||||
when that adapter wires the capability into its own dispatch surface, not merely that a
|
|
||||||
shared core primitive exists.
|
|
||||||
|
|
||||||
Legend: ✅ supported · ◑ partial · ❌ not implemented · — not applicable to this transport
|
|
||||||
|
|
||||||
### Protocol core
|
|
||||||
|
|
||||||
The surface every Mizan adapter implements.
|
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| RPC call dispatch (`{result, invalidate}`) | ✅ | ✅ | ✅ | ✅ ¹ | ✅ |
|
|
||||||
| Named-context bundle fetch | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Invalidation — JSON body | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Invalidation auto-scoping (three-tier) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Function discovery / registration | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Codegen IR export (KDL) | ✅ | ✅ | ✅ ⁶ | ✅ ⁶ | — ⁸ |
|
|
||||||
|
|
||||||
### Edge, cache & enforcement
|
|
||||||
|
|
||||||
Protocol transports and guarantees co-equal with the body channel in the spec.
|
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| Invalidation — `X-Mizan-Invalidate` header | ✅ | ❌ | ❌ | — ¹ | ✅ |
|
|
||||||
| Auth-guard enforcement (`auth=…` rejects) | ✅ | ✅ | ❌ ⁵ | ◑ ⁵ | ❌ |
|
|
||||||
| Origin-side HMAC cache | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|
||||||
| Edge manifest export | ✅ | ❌ | ❌ | — | ✅ |
|
|
||||||
| PSR (`render_strategy` in manifest) | ✅ | ❌ | ❌ | — | ✅ |
|
|
||||||
| Session / CSRF init endpoint | ✅ | ◑ ⁷ | ◑ ⁷ | — | ❌ |
|
|
||||||
|
|
||||||
> **Caveat:** Rust/Axum and Tauri accept `auth=` on a function but do not yet enforce
|
|
||||||
> it — do not rely on `auth=` for access control on those adapters.
|
|
||||||
|
|
||||||
### Stack extensions (Django)
|
|
||||||
|
|
||||||
Django ecosystem features Mizan wraps. Other adapters provide these only where the
|
|
||||||
target stack calls for them.
|
|
||||||
|
|
||||||
| Capability | Django | FastAPI | Rust / Axum | Tauri | TypeScript |
|
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| WebSocket channels (declared transport) | ✅ | ❌ | ◑ ² | ❌ | ❌ |
|
|
||||||
| Forms (schema / validate / submit) | ✅ | ❌ | ◑ ³ | ❌ | ❌ |
|
|
||||||
| Formsets | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
||||||
| API shapes (ORM query projection) ⁴ | ✅ | — | — | — | — |
|
|
||||||
| JWT auth (access / refresh, session validation) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
||||||
| MWT (edge identity token) | ✅ | ❌ | ❌ | — | ❌ |
|
|
||||||
| SSR bridge | ✅ | ❌ | ❌ | — | ❌ |
|
|
||||||
| Auth-provider integration (allauth) | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
||||||
|
|
||||||
**Notes**
|
|
||||||
|
|
||||||
1. Tauri's transport is Tauri IPC (a single `#[tauri::command]` envelope), not HTTP.
|
|
||||||
Invalidation rides in the JSON response body; there is no header channel.
|
|
||||||
2. Rust/Axum declares `Transport::Websocket` in the IR/macro but routes no Axum
|
|
||||||
WebSocket handler yet.
|
|
||||||
3. Rust/Axum carries `is_form`/`form_role` trait stubs but no validate/submit endpoint.
|
|
||||||
4. "API shapes" is Django's django-readers queryset projection — ORM-coupled. Every
|
|
||||||
adapter carries typed input/output through the KDL IR; the projection primitive
|
|
||||||
itself is Django-only.
|
|
||||||
5. Tauri's `FunctionSpec` carries `auth`/`private` fields; the dispatch path does not
|
|
||||||
enforce them. Rust/Axum has no enforcement either.
|
|
||||||
6. Rust/Axum and Tauri are the IR authority via the `#[mizan::client]` macro + linkme
|
|
||||||
registry; the codegen links the crate directly (`build_ir()` / the `export-ir` bin)
|
|
||||||
rather than fetching over HTTP.
|
|
||||||
7. FastAPI and Rust/Axum expose `GET /session/` returning a null CSRF token for wire
|
|
||||||
parity; CSRF is Django-only.
|
|
||||||
8. TypeScript is an edge/protocol-reference adapter (HMAC cache, manifest, PSR), not a
|
|
||||||
codegen source — it demonstrates the cache + invalidation protocol is
|
|
||||||
language-agnostic.
|
|
||||||
|
|
||||||
## Conformance
|
|
||||||
|
|
||||||
Adapter parity is gated by the AFI conformance suite in [`tests/afi/`](tests/afi/). It
|
|
||||||
currently asserts **IR-shape parity** — the same fixture through Django, FastAPI, and
|
|
||||||
the Rust adapter emits byte-identical KDL (`test_codegen_parity.py`). Per-capability
|
|
||||||
runtime assertions (header transport, `auth=` enforcement, cache behavior) are planned.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ function. Typed React client generated. Invalidation automatic.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv add "mizan[channels]"
|
uv add "mizan[channels]"
|
||||||
# or with allauth integration:
|
|
||||||
uv add "mizan[channels,allauth]"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -116,6 +114,9 @@ class ContactForm(mizanFormMixin, forms.Form):
|
|||||||
Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Frontend
|
Auto-registers `contact.schema`, `contact.validate`, `contact.submit`. Frontend
|
||||||
gets `useContactForm()`.
|
gets `useContactForm()`.
|
||||||
|
|
||||||
|
Auth-provider forms (django-allauth login, signup, MFA, WebAuthn) live in the
|
||||||
|
dedicated `mizan-allauth` repository, built on this mixin.
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
WebSocket-native RPC via a flag flip:
|
WebSocket-native RPC via a flag flip:
|
||||||
|
|||||||
@@ -25,12 +25,6 @@ channels = [
|
|||||||
"channels>=4.0",
|
"channels>=4.0",
|
||||||
"channels-redis>=4.0",
|
"channels-redis>=4.0",
|
||||||
]
|
]
|
||||||
allauth = [
|
|
||||||
"django-allauth>=65.0",
|
|
||||||
]
|
|
||||||
webauthn = [
|
|
||||||
"fido2>=2.0",
|
|
||||||
]
|
|
||||||
shapes = [
|
shapes = [
|
||||||
"django-readers>=2.0",
|
"django-readers>=2.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
"""
|
|
||||||
mizan Allauth Integration
|
|
||||||
|
|
||||||
Backend support for django-allauth with mizan server functions.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
- Auth contexts (auth_status, user) - required by frontend allauth module
|
|
||||||
- Allauth form wrappers - expose allauth forms as server functions
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# In your app's apps.py
|
|
||||||
class MyAppConfig(AppConfig):
|
|
||||||
def ready(self):
|
|
||||||
import mizan.allauth.forms # noqa - registers forms
|
|
||||||
import mizan.allauth.contexts # noqa - registers contexts
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .contexts import auth_status, user, AuthStatusOutput, UserOutput
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"auth_status",
|
|
||||||
"user",
|
|
||||||
"AuthStatusOutput",
|
|
||||||
"UserOutput",
|
|
||||||
]
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
"""
|
|
||||||
Auth contexts for mizan Allauth integration.
|
|
||||||
|
|
||||||
These are the core auth primitives that the frontend allauth module depends on.
|
|
||||||
Separated into two concerns:
|
|
||||||
|
|
||||||
- auth_status: Authentication state and permission guards (fast, no DB hit with JWT)
|
|
||||||
- user: Full user profile data (may require DB query for JWT auth)
|
|
||||||
|
|
||||||
Both are registered as global contexts for SSR hydration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from mizan.client import client
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Auth Status Context
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class AuthStatusOutput(BaseModel):
|
|
||||||
"""Authentication status and permission guards."""
|
|
||||||
|
|
||||||
is_authenticated: bool
|
|
||||||
user_id: int | None = None
|
|
||||||
is_staff: bool = False
|
|
||||||
is_superuser: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@client(context="global")
|
|
||||||
def auth_status(request: HttpRequest) -> AuthStatusOutput:
|
|
||||||
"""
|
|
||||||
Auth status context - provides authentication state and guards.
|
|
||||||
|
|
||||||
This works identically for both session and JWT auth. The data comes
|
|
||||||
from the request.user object (either full User or JWTUser with claims).
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
const auth = useAuthStatus()
|
|
||||||
if (auth.is_authenticated) { ... }
|
|
||||||
if (auth.is_staff) { ... }
|
|
||||||
"""
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return AuthStatusOutput(is_authenticated=False)
|
|
||||||
|
|
||||||
return AuthStatusOutput(
|
|
||||||
is_authenticated=True,
|
|
||||||
user_id=user.id,
|
|
||||||
is_staff=user.is_staff,
|
|
||||||
is_superuser=user.is_superuser,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# User Profile Context
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class UserOutput(BaseModel):
|
|
||||||
"""Full user profile data."""
|
|
||||||
|
|
||||||
id: int
|
|
||||||
email: str
|
|
||||||
first_name: str = ""
|
|
||||||
last_name: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@client(context="global")
|
|
||||||
def user(request: HttpRequest) -> UserOutput | None:
|
|
||||||
"""
|
|
||||||
User profile context - provides full user data.
|
|
||||||
|
|
||||||
Unlike auth_status, this may require a DB query (for JWT auth where
|
|
||||||
the user object is a minimal JWTUser with only claims).
|
|
||||||
|
|
||||||
Returns None if not authenticated.
|
|
||||||
|
|
||||||
Frontend:
|
|
||||||
const user = useUser()
|
|
||||||
if (user) {
|
|
||||||
console.log(user.email)
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
req_user = request.user
|
|
||||||
|
|
||||||
if not req_user.is_authenticated:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Check if we have full user data or just JWT claims
|
|
||||||
if hasattr(req_user, "email") and req_user.email:
|
|
||||||
# Full User object (session auth)
|
|
||||||
return UserOutput(
|
|
||||||
id=req_user.id,
|
|
||||||
email=req_user.email,
|
|
||||||
first_name=getattr(req_user, "first_name", "") or "",
|
|
||||||
last_name=getattr(req_user, "last_name", "") or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
# JWTUser - need to fetch from DB
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
try:
|
|
||||||
db_user = User.objects.get(pk=req_user.id)
|
|
||||||
return UserOutput(
|
|
||||||
id=db_user.id,
|
|
||||||
email=db_user.email,
|
|
||||||
first_name=db_user.first_name or "",
|
|
||||||
last_name=db_user.last_name or "",
|
|
||||||
)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
return None
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
"""
|
|
||||||
Allauth forms as mizan server functions.
|
|
||||||
|
|
||||||
This module wraps allauth forms with mizanFormMixin, exposing them as
|
|
||||||
typed server functions for the React frontend.
|
|
||||||
|
|
||||||
Each form becomes three server functions:
|
|
||||||
- {name}.schema - Get form field definitions
|
|
||||||
- {name}.validate - Validate form data
|
|
||||||
- {name}.submit - Submit form
|
|
||||||
|
|
||||||
Import this module in your app's ready() to register the forms:
|
|
||||||
|
|
||||||
class MyAppConfig(AppConfig):
|
|
||||||
def ready(self):
|
|
||||||
import mizan.allauth.forms # noqa
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from django.http import HttpRequest
|
|
||||||
|
|
||||||
from mizan.forms import mizanFormMixin, mizanFormMeta
|
|
||||||
|
|
||||||
# Account forms
|
|
||||||
from allauth.account.forms import (
|
|
||||||
AddEmailForm,
|
|
||||||
ChangePasswordForm,
|
|
||||||
ConfirmLoginCodeForm,
|
|
||||||
LoginForm,
|
|
||||||
RequestLoginCodeForm,
|
|
||||||
ResetPasswordForm,
|
|
||||||
ResetPasswordKeyForm,
|
|
||||||
SetPasswordForm,
|
|
||||||
SignupForm,
|
|
||||||
UserTokenForm,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Password reauthentication form - conditionally import
|
|
||||||
try:
|
|
||||||
from allauth.account.forms import ReauthenticateForm
|
|
||||||
|
|
||||||
HAS_REAUTH = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_REAUTH = False
|
|
||||||
|
|
||||||
# MFA forms - conditionally import
|
|
||||||
try:
|
|
||||||
from allauth.mfa.base.forms import AuthenticateForm as MFAAuthenticateForm
|
|
||||||
from allauth.mfa.base.forms import ReauthenticateForm as MFAReauthenticateForm
|
|
||||||
from allauth.mfa.totp.forms import ActivateTOTPForm, DeactivateTOTPForm
|
|
||||||
from allauth.mfa.recovery_codes.forms import GenerateRecoveryCodesForm
|
|
||||||
|
|
||||||
HAS_MFA = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_MFA = False
|
|
||||||
|
|
||||||
# WebAuthn forms (if available)
|
|
||||||
try:
|
|
||||||
from allauth.mfa.webauthn.forms import AuthenticateWebAuthnForm
|
|
||||||
|
|
||||||
HAS_WEBAUTHN = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_WEBAUTHN = False
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from mizan.forms.schemas import FormValidation
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Account Forms
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class mizanLoginForm(LoginForm, mizanFormMixin):
|
|
||||||
"""Sign in with email and password."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="login",
|
|
||||||
title="Sign In",
|
|
||||||
subtitle="Welcome back. Enter your credentials to continue.",
|
|
||||||
submit_label="Sign In",
|
|
||||||
live_validation=False, # Don't validate credentials as user types
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.login(request)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanSignupForm(SignupForm, mizanFormMixin):
|
|
||||||
"""Create a new account."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="signup",
|
|
||||||
title="Create Account",
|
|
||||||
subtitle="Enter your details to get started.",
|
|
||||||
submit_label="Create Account",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save(request)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanAddEmailForm(AddEmailForm, mizanFormMixin):
|
|
||||||
"""Add another email address to your account."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="add_email",
|
|
||||||
title="Add Email Address",
|
|
||||||
subtitle="Add another email address to your account.",
|
|
||||||
submit_label="Add Email",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanChangePasswordForm(ChangePasswordForm, mizanFormMixin):
|
|
||||||
"""Change your account password."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="change_password",
|
|
||||||
title="Change Password",
|
|
||||||
subtitle="Update your password to keep your account secure.",
|
|
||||||
submit_label="Change Password",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanSetPasswordForm(SetPasswordForm, mizanFormMixin):
|
|
||||||
"""Set a password for accounts created via social login."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="set_password",
|
|
||||||
title="Set Password",
|
|
||||||
subtitle="Create a password for your account.",
|
|
||||||
submit_label="Set Password",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanResetPasswordForm(ResetPasswordForm, mizanFormMixin):
|
|
||||||
"""Request a password reset email."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="reset_password",
|
|
||||||
title="Reset Password",
|
|
||||||
subtitle="Enter your email address and we'll send you a link to reset your password.",
|
|
||||||
submit_label="Send Reset Link",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save(request)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanResetPasswordKeyForm(ResetPasswordKeyForm, mizanFormMixin):
|
|
||||||
"""Set a new password using a reset key."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="reset_password_from_key",
|
|
||||||
title="Set New Password",
|
|
||||||
subtitle="Enter your new password below.",
|
|
||||||
submit_label="Reset Password",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanRequestLoginCodeForm(RequestLoginCodeForm, mizanFormMixin):
|
|
||||||
"""Request a login code via email."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="request_login_code",
|
|
||||||
title="Sign In with Code",
|
|
||||||
subtitle="Enter your email address and we'll send you a login code.",
|
|
||||||
submit_label="Send Code",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanConfirmLoginCodeForm(ConfirmLoginCodeForm, mizanFormMixin):
|
|
||||||
"""Confirm a login code."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="confirm_login_code",
|
|
||||||
title="Enter Code",
|
|
||||||
subtitle="Enter the code we sent to your email.",
|
|
||||||
submit_label="Verify Code",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class mizanUserTokenForm(UserTokenForm, mizanFormMixin):
|
|
||||||
"""Verify an email with a token."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="user_token",
|
|
||||||
title="Verify Email",
|
|
||||||
subtitle="Enter the verification code from your email.",
|
|
||||||
submit_label="Verify",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Password reauthentication - conditionally define
|
|
||||||
if HAS_REAUTH:
|
|
||||||
|
|
||||||
class mizanReauthenticateForm(ReauthenticateForm, mizanFormMixin):
|
|
||||||
"""Re-authenticate with password for sensitive actions."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="reauthenticate",
|
|
||||||
title="Confirm Your Identity",
|
|
||||||
subtitle="Please enter your password to continue.",
|
|
||||||
submit_label="Confirm",
|
|
||||||
live_validation=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
from allauth.account.internal.flows import reauthentication
|
|
||||||
|
|
||||||
reauthentication.reauthenticate_by_password(request)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# MFA Forms
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
if HAS_MFA:
|
|
||||||
|
|
||||||
class mizanMFAAuthenticateForm(MFAAuthenticateForm, mizanFormMixin):
|
|
||||||
"""Authenticate with MFA during login."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="mfa_authenticate",
|
|
||||||
title="Two-Factor Authentication",
|
|
||||||
subtitle="Enter your authentication code to continue.",
|
|
||||||
submit_label="Verify",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
class mizanMFAReauthenticateForm(MFAReauthenticateForm, mizanFormMixin):
|
|
||||||
"""Re-authenticate with MFA for sensitive actions."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="mfa_reauthenticate",
|
|
||||||
title="Confirm Your Identity",
|
|
||||||
subtitle="Enter your authentication code to continue.",
|
|
||||||
submit_label="Confirm",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
class mizanActivateTOTPForm(ActivateTOTPForm, mizanFormMixin):
|
|
||||||
"""Activate TOTP authenticator."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="activate_totp",
|
|
||||||
title="Set Up Authenticator",
|
|
||||||
subtitle="Enter the code from your authenticator app to complete setup.",
|
|
||||||
submit_label="Activate",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
class mizanDeactivateTOTPForm(DeactivateTOTPForm, mizanFormMixin):
|
|
||||||
"""Deactivate TOTP authenticator."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="deactivate_totp",
|
|
||||||
title="Disable Authenticator",
|
|
||||||
subtitle="Enter your password to disable two-factor authentication.",
|
|
||||||
submit_label="Disable",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
class mizanGenerateRecoveryCodesForm(GenerateRecoveryCodesForm, mizanFormMixin):
|
|
||||||
"""Generate new recovery codes."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="generate_recovery_codes",
|
|
||||||
title="Recovery Codes",
|
|
||||||
subtitle="Generate new recovery codes for your account.",
|
|
||||||
submit_label="Generate Codes",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
if HAS_WEBAUTHN:
|
|
||||||
|
|
||||||
class mizanAuthenticateWebAuthnForm(AuthenticateWebAuthnForm, mizanFormMixin):
|
|
||||||
"""Authenticate with WebAuthn security key."""
|
|
||||||
|
|
||||||
mizan = mizanFormMeta(
|
|
||||||
name="webauthn_authenticate",
|
|
||||||
title="Security Key",
|
|
||||||
subtitle="Use your security key to authenticate.",
|
|
||||||
submit_label="Use Security Key",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_init_kwargs(cls, request: HttpRequest) -> dict[str, Any]:
|
|
||||||
return {"request": request, "user": request.user}
|
|
||||||
|
|
||||||
def on_submit_success(self, request: HttpRequest) -> dict | None:
|
|
||||||
self.save()
|
|
||||||
return None
|
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
"""
|
|
||||||
JWT Hybrid Settings
|
|
||||||
|
|
||||||
Configuration is read from Django settings with sensible defaults.
|
|
||||||
Supports both symmetric (HS256) and asymmetric (RS256) algorithms.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
@@ -13,8 +6,6 @@ from django.conf import settings as django_settings
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class JWTSettings:
|
class JWTSettings:
|
||||||
"""JWT configuration."""
|
|
||||||
|
|
||||||
# Signing keys
|
# Signing keys
|
||||||
private_key: str # Used for signing (required)
|
private_key: str # Used for signing (required)
|
||||||
public_key: str # Used for verification (same as private for HS256)
|
public_key: str # Used for verification (same as private for HS256)
|
||||||
@@ -33,26 +24,8 @@ class JWTSettings:
|
|||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> JWTSettings:
|
def get_settings() -> JWTSettings:
|
||||||
"""
|
|
||||||
Load JWT settings from Django settings.
|
|
||||||
|
|
||||||
Settings:
|
|
||||||
JWT_PRIVATE_KEY: Signing key (required)
|
|
||||||
JWT_PUBLIC_KEY: Verification key (defaults to private key for HS256)
|
|
||||||
JWT_ALGORITHM: Algorithm to use (default: HS256)
|
|
||||||
JWT_ACCESS_TOKEN_EXPIRES_IN: Access token lifetime (default: 300)
|
|
||||||
JWT_REFRESH_TOKEN_EXPIRES_IN: Refresh token lifetime (default: 604800)
|
|
||||||
JWT_VALIDATE_SESSION: Validate session on token use (default: True)
|
|
||||||
JWT_ROTATE_REFRESH_TOKEN: Rotate refresh tokens (default: True)
|
|
||||||
"""
|
|
||||||
private_key = getattr(django_settings, "JWT_PRIVATE_KEY", None)
|
private_key = getattr(django_settings, "JWT_PRIVATE_KEY", None)
|
||||||
|
|
||||||
if not private_key:
|
|
||||||
# Fall back to allauth setting if available (for compatibility)
|
|
||||||
headless_key = getattr(django_settings, "HEADLESS_JWT_PRIVATE_KEY", None)
|
|
||||||
if headless_key:
|
|
||||||
private_key = headless_key
|
|
||||||
|
|
||||||
if private_key is None:
|
if private_key is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"JWT_PRIVATE_KEY must be set in Django settings. "
|
"JWT_PRIVATE_KEY must be set in Django settings. "
|
||||||
@@ -60,7 +33,6 @@ def get_settings() -> JWTSettings:
|
|||||||
"For RS256, use a PEM-encoded RSA private key."
|
"For RS256, use a PEM-encoded RSA private key."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-detect algorithm based on key format if not explicitly set
|
|
||||||
algorithm = getattr(django_settings, "JWT_ALGORITHM", None)
|
algorithm = getattr(django_settings, "JWT_ALGORITHM", None)
|
||||||
|
|
||||||
if algorithm is None:
|
if algorithm is None:
|
||||||
@@ -100,14 +72,10 @@ def get_settings() -> JWTSettings:
|
|||||||
public_key=public_key,
|
public_key=public_key,
|
||||||
algorithm=algorithm,
|
algorithm=algorithm,
|
||||||
access_token_expires_in=getattr(
|
access_token_expires_in=getattr(
|
||||||
django_settings,
|
django_settings, "JWT_ACCESS_TOKEN_EXPIRES_IN", 300
|
||||||
"JWT_ACCESS_TOKEN_EXPIRES_IN",
|
|
||||||
getattr(django_settings, "HEADLESS_JWT_ACCESS_TOKEN_EXPIRES_IN", 300),
|
|
||||||
),
|
),
|
||||||
refresh_token_expires_in=getattr(
|
refresh_token_expires_in=getattr(
|
||||||
django_settings,
|
django_settings, "JWT_REFRESH_TOKEN_EXPIRES_IN", 604800
|
||||||
"JWT_REFRESH_TOKEN_EXPIRES_IN",
|
|
||||||
getattr(django_settings, "HEADLESS_JWT_REFRESH_TOKEN_EXPIRES_IN", 604800),
|
|
||||||
),
|
),
|
||||||
validate_session=getattr(
|
validate_session=getattr(
|
||||||
django_settings, "JWT_VALIDATE_SESSION", True
|
django_settings, "JWT_VALIDATE_SESSION", True
|
||||||
|
|||||||
@@ -1,19 +1,4 @@
|
|||||||
"""
|
import logging
|
||||||
mizan Auto-Discovery
|
|
||||||
|
|
||||||
Scans Django apps for server functions following the 'clients' layer convention:
|
|
||||||
- <app>/clients.py
|
|
||||||
- <app>/clients/**/*.py
|
|
||||||
|
|
||||||
Usage in urls.py:
|
|
||||||
from mizan.setup.discovery import mizan_clients
|
|
||||||
|
|
||||||
mizan_clients('apps') # Scans apps/*/clients.py
|
|
||||||
mizan_clients('mizan', 'allauth') # Scans mizan/allauth/**/*.py
|
|
||||||
|
|
||||||
This replaces manual "import to register" patterns with explicit auto-discovery.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
||||||
@@ -21,14 +6,13 @@ from mizan._vendor.app_visitor import DjangoAppVisitor, get_members
|
|||||||
from mizan_core.registry import register, get_function
|
from mizan_core.registry import register, get_function
|
||||||
from mizan_core.client.function import ServerFunction
|
from mizan_core.client.function import ServerFunction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _RegisterServerFunctions:
|
class _RegisterServerFunctions:
|
||||||
"""Visitor handler that registers ServerFunction subclasses."""
|
|
||||||
|
|
||||||
def on_module(
|
def on_module(
|
||||||
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
self, app_name: str, path_parts: list[str], members: list[tuple[str, Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Process discovered module members."""
|
|
||||||
for name, member in members:
|
for name, member in members:
|
||||||
# Register ServerFunction subclasses
|
# Register ServerFunction subclasses
|
||||||
if (
|
if (
|
||||||
@@ -47,44 +31,23 @@ class _RegisterServerFunctions:
|
|||||||
try:
|
try:
|
||||||
register(member, fn_name)
|
register(member, fn_name)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# Already registered with different class - skip
|
logger.warning(
|
||||||
pass
|
"Server function name %r already registered with a "
|
||||||
|
"different class; skipping %s.%s",
|
||||||
|
fn_name,
|
||||||
|
member.__module__,
|
||||||
|
member.__qualname__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Scans <app>/<layer>.py and <app>/<layer>/**/*.py under apps_root
|
||||||
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
def mizan_clients(apps_root: str, layer: str = "clients") -> None:
|
||||||
"""
|
|
||||||
Discover and register server functions from Django apps.
|
|
||||||
|
|
||||||
Scans for the specified layer (default: 'clients') in each app:
|
|
||||||
- <app>/<layer>.py
|
|
||||||
- <app>/<layer>/**/*.py
|
|
||||||
|
|
||||||
Args:
|
|
||||||
apps_root: Root package containing Django apps (e.g., 'apps')
|
|
||||||
layer: Module name pattern to scan (default: 'clients')
|
|
||||||
|
|
||||||
Example:
|
|
||||||
# In urls.py
|
|
||||||
mizan_clients('apps') # Scans apps/*/clients.py
|
|
||||||
mizan_clients('apps', 'functions') # Scans apps/*/functions.py
|
|
||||||
"""
|
|
||||||
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
visitor = DjangoAppVisitor(layer=layer, apps_root=apps_root)
|
||||||
visitor.visit(_RegisterServerFunctions())
|
visitor.visit(_RegisterServerFunctions())
|
||||||
|
|
||||||
|
|
||||||
|
# Registers server functions from one module path, e.g. 'mizan.jwt.functions'
|
||||||
def mizan_module(module_path: str) -> None:
|
def mizan_module(module_path: str) -> None:
|
||||||
"""
|
|
||||||
Register server functions from a specific module.
|
|
||||||
|
|
||||||
Use this for library modules that don't follow the app convention.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
module_path: Full module path (e.g., 'mizan.integrations.allauth')
|
|
||||||
|
|
||||||
Example:
|
|
||||||
mizan_module('mizan.integrations.allauth')
|
|
||||||
mizan_module('mizan.jwt.functions')
|
|
||||||
"""
|
|
||||||
members = get_members(module_path)
|
members = get_members(module_path)
|
||||||
handler = _RegisterServerFunctions()
|
handler = _RegisterServerFunctions()
|
||||||
handler.on_module("", [], members)
|
handler.on_module("", [], members)
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ directory (Stage 1 is auto-included whenever `react` is a target):
|
|||||||
| `@rythazhur/mizan/channels` | WebSocket channels |
|
| `@rythazhur/mizan/channels` | WebSocket channels |
|
||||||
| `@rythazhur/mizan/jwt` | JWT token management |
|
| `@rythazhur/mizan/jwt` | JWT token management |
|
||||||
| `@rythazhur/mizan/client` | HTTP clients (CSR/SSR) |
|
| `@rythazhur/mizan/client` | HTTP clients (CSR/SSR) |
|
||||||
| `@rythazhur/mizan/allauth` | Allauth UI components |
|
|
||||||
|
|
||||||
These are **library internals** used by the generated code. You should import from `@/api` (your generated index), not from the library directly.
|
These are **library internals** used by the generated code. You should import from `@/api` (your generated index), not from the library directly.
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +1,2 @@
|
|||||||
/**
|
|
||||||
* mizan/jwt
|
|
||||||
*
|
|
||||||
* JWT token management via mizan server functions.
|
|
||||||
* Handles token lifecycle: obtain, refresh, clear.
|
|
||||||
*
|
|
||||||
* ## Quick Start
|
|
||||||
*
|
|
||||||
* Use JWTContext in authenticated areas (e.g., inside UserRoute):
|
|
||||||
*
|
|
||||||
* ```tsx
|
|
||||||
* import { JWTContext } from 'mizan/jwt'
|
|
||||||
* import { UserRoute } from 'mizan/allauth'
|
|
||||||
*
|
|
||||||
* function ProtectedPage() {
|
|
||||||
* return (
|
|
||||||
* <UserRoute>
|
|
||||||
* <JWTContext>
|
|
||||||
* <MyProtectedContent />
|
|
||||||
* </JWTContext>
|
|
||||||
* </UserRoute>
|
|
||||||
* )
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Then use JWT-authenticated requests:
|
|
||||||
*
|
|
||||||
* ```tsx
|
|
||||||
* import { useDjangoCSRClient, Auth } from 'mizan/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/mizan/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/mizan/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 'mizan/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 { JWTContext, useJWT, useJWTRequired, useJWTReady } from './JWTContext'
|
||||||
export type { JWTTokens, JWTConfig, JWTState } from '../client/types'
|
export type { JWTTokens, JWTConfig, JWTState } from '../client/types'
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
'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)
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
'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
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Re-export RouterAdapter from mizan/client.
|
|
||||||
*
|
|
||||||
* Allauth extends this with a required getParam method.
|
|
||||||
*/
|
|
||||||
import type { RouterAdapter as BaseRouterAdapter } from 'mizan/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
|
|
||||||
}
|
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
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>
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
'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 },
|
|
||||||
] : []}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
'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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { FormEvent, useEffect, useState } from 'react'
|
|
||||||
import {
|
|
||||||
useDjangoFormCore,
|
|
||||||
type DjangoFormState,
|
|
||||||
type FormOptions,
|
|
||||||
type FormErrors,
|
|
||||||
} from 'mizan'
|
|
||||||
import { useAuthContext } from '../contexts/AuthContext'
|
|
||||||
import { useStyles } from '../contexts/StylesContext'
|
|
||||||
import { getAuthDetails, AuthDetails } from '../api'
|
|
||||||
|
|
||||||
interface FooterLink {
|
|
||||||
label: string
|
|
||||||
href?: string
|
|
||||||
onClick?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthDjangoFormProps {
|
|
||||||
/** Form name (e.g., "login", "signup", "change_password") */
|
|
||||||
formName: string
|
|
||||||
/** Callback after successful form submission */
|
|
||||||
onSuccess?: (result: any, authDetails: AuthDetails) => void
|
|
||||||
/** Callback after failed form submission */
|
|
||||||
onError?: (errors: any) => void
|
|
||||||
/** Links to show in footer (e.g., "Forgot password?") */
|
|
||||||
footerLinks?: FooterLink[]
|
|
||||||
/** Content to render before form fields */
|
|
||||||
preFields?: React.ReactNode
|
|
||||||
/** Content to render after form fields (before submit button) */
|
|
||||||
postFields?: React.ReactNode
|
|
||||||
/** Override the submit button label from schema */
|
|
||||||
submitLabel?: string
|
|
||||||
/** Override the title from schema */
|
|
||||||
title?: string
|
|
||||||
/** Override the subtitle from schema */
|
|
||||||
subtitle?: string
|
|
||||||
/** Options for form behavior (validation, schema refetch, etc.) */
|
|
||||||
formOptions?: FormOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AuthDjangoForm renders a form from the mizan server functions
|
|
||||||
* with styling consistent with the auth UI.
|
|
||||||
*
|
|
||||||
* It fetches the form schema (including title, subtitle, fields, submit label)
|
|
||||||
* from the backend and renders it dynamically with real-time validation.
|
|
||||||
*/
|
|
||||||
export function AuthDjangoForm({
|
|
||||||
formName,
|
|
||||||
onSuccess,
|
|
||||||
onError,
|
|
||||||
footerLinks,
|
|
||||||
preFields,
|
|
||||||
postFields,
|
|
||||||
submitLabel,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
formOptions,
|
|
||||||
}: AuthDjangoFormProps) {
|
|
||||||
const form = useDjangoFormCore<Record<string, unknown>>({
|
|
||||||
name: formName,
|
|
||||||
options: formOptions,
|
|
||||||
})
|
|
||||||
const { refresh } = useAuthContext()
|
|
||||||
const styles = useStyles()
|
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
|
|
||||||
// Hydration safety: only render inputs after mount
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const result = await form.submit()
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Refresh auth state and get the updated auth for callbacks
|
|
||||||
const newAuth = await refresh()
|
|
||||||
onSuccess?.(result.data, getAuthDetails(newAuth))
|
|
||||||
} else {
|
|
||||||
onError?.(result.errors)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (form.loading) {
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.card}>
|
|
||||||
<div className={styles.loading}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get form-level errors (non-field errors like "Invalid credentials")
|
|
||||||
// These only appear after submission due to 'field-only' default
|
|
||||||
const formErrors = form.getFormErrors()
|
|
||||||
|
|
||||||
// Use prop overrides or schema values
|
|
||||||
const displayTitle = title ?? form.schema?.title
|
|
||||||
const displaySubtitle = subtitle ?? form.schema?.subtitle
|
|
||||||
const displaySubmitLabel = submitLabel ?? form.schema?.submit_label ?? 'Submit'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.card}>
|
|
||||||
{displayTitle && (
|
|
||||||
<h1 className={styles.title}>{displayTitle}</h1>
|
|
||||||
)}
|
|
||||||
{displaySubtitle && (
|
|
||||||
<p className={styles.subtitle}>{displaySubtitle}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Form-level errors (shown after submission) */}
|
|
||||||
{formErrors.length > 0 && (
|
|
||||||
<div className={styles.error}>
|
|
||||||
{formErrors.map((err, i) => (
|
|
||||||
<p key={i}>{err.message}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className={styles.form}>
|
|
||||||
{preFields}
|
|
||||||
|
|
||||||
<div className={styles.fieldsContainer}>
|
|
||||||
{form.schema?.fieldOrder.map(fieldName => {
|
|
||||||
const field = form.schema!.fields[fieldName]
|
|
||||||
return (
|
|
||||||
<AuthField
|
|
||||||
key={fieldName}
|
|
||||||
field={{
|
|
||||||
name: fieldName,
|
|
||||||
label: field.label,
|
|
||||||
type: field.type,
|
|
||||||
widget: field.widget,
|
|
||||||
required: field.required,
|
|
||||||
disabled: field.disabled,
|
|
||||||
help_text: field.help_text,
|
|
||||||
max_length: field.max_length,
|
|
||||||
choices: field.choices,
|
|
||||||
}}
|
|
||||||
value={form.data[fieldName]}
|
|
||||||
mounted={mounted}
|
|
||||||
touched={form.touchedFields.has(fieldName)}
|
|
||||||
errors={form.getFieldErrors(fieldName)}
|
|
||||||
onChange={(value) => form.set(fieldName, value)}
|
|
||||||
onBlur={() => form.touch(fieldName)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{postFields}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={form.submitting || form.validating}
|
|
||||||
className={styles.submit}
|
|
||||||
>
|
|
||||||
{form.submitting ? 'Submitting...' : displaySubmitLabel}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{footerLinks && footerLinks.length > 0 && (
|
|
||||||
<div className={styles.footer}>
|
|
||||||
{footerLinks.map((link, i) => (
|
|
||||||
link.onClick ? (
|
|
||||||
<button key={i} type="button" onClick={link.onClick} className={styles.link}>
|
|
||||||
{link.label}
|
|
||||||
</button>
|
|
||||||
) : link.href ? (
|
|
||||||
<a key={i} href={link.href} className={styles.link}>
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
) : null
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal field component with hydration-safe rendering
|
|
||||||
*/
|
|
||||||
interface AuthFieldProps {
|
|
||||||
field: {
|
|
||||||
name: string
|
|
||||||
label: string
|
|
||||||
type: string
|
|
||||||
widget: string
|
|
||||||
required: boolean
|
|
||||||
disabled: boolean
|
|
||||||
help_text: string
|
|
||||||
max_length?: number | null
|
|
||||||
choices?: Array<{ value: string; label: string }> | null
|
|
||||||
}
|
|
||||||
value: any
|
|
||||||
mounted: boolean
|
|
||||||
touched: boolean
|
|
||||||
errors: Array<{ message: string }>
|
|
||||||
onChange: (value: any) => void
|
|
||||||
onBlur: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthField({ field, value, mounted, touched, errors, onChange, onBlur }: AuthFieldProps) {
|
|
||||||
const styles = useStyles()
|
|
||||||
|
|
||||||
const renderInput = () => {
|
|
||||||
// Select dropdown
|
|
||||||
if (field.choices && (field.widget === 'Select' || field.type === 'select')) {
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
value={value || ''}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
onBlur={onBlur}
|
|
||||||
required={field.required}
|
|
||||||
disabled={field.disabled}
|
|
||||||
className={styles.fieldInput}
|
|
||||||
>
|
|
||||||
{field.choices.map((choice) => (
|
|
||||||
<option key={choice.value} value={choice.value}>
|
|
||||||
{choice.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Radio buttons
|
|
||||||
if (field.choices && field.widget === 'RadioSelect') {
|
|
||||||
return (
|
|
||||||
<div className={styles.radioGroup}>
|
|
||||||
{field.choices.map((choice) => (
|
|
||||||
<label key={choice.value} className={styles.radioItem}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={field.name}
|
|
||||||
value={choice.value}
|
|
||||||
checked={value === choice.value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
onBlur={onBlur}
|
|
||||||
required={field.required}
|
|
||||||
disabled={field.disabled}
|
|
||||||
/>
|
|
||||||
<span>{choice.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Checkbox
|
|
||||||
if (field.type === 'checkbox') {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!!value}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
onBlur={onBlur}
|
|
||||||
required={field.required}
|
|
||||||
disabled={field.disabled}
|
|
||||||
className={styles.checkbox}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Textarea
|
|
||||||
if (field.widget === 'Textarea') {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
value={value || ''}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
onBlur={onBlur}
|
|
||||||
required={field.required}
|
|
||||||
disabled={field.disabled}
|
|
||||||
maxLength={field.max_length || undefined}
|
|
||||||
className={styles.fieldInput}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: text input (text, password, email, etc.)
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type={field.type}
|
|
||||||
value={value || ''}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
onBlur={onBlur}
|
|
||||||
required={field.required}
|
|
||||||
disabled={field.disabled}
|
|
||||||
maxLength={field.max_length || undefined}
|
|
||||||
className={styles.fieldInput}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.field}>
|
|
||||||
<label className={styles.fieldLabel}>
|
|
||||||
{field.label}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Hydration-safe: render placeholder until mounted */}
|
|
||||||
{mounted ? (
|
|
||||||
renderInput()
|
|
||||||
) : (
|
|
||||||
<div className={styles.fieldInput} style={{ minHeight: '2.75rem' }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Field errors (only show if touched) */}
|
|
||||||
{touched && errors.map((err, i) => (
|
|
||||||
<p key={i} className={styles.fieldError}>{err.message}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// 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'
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useAllauthAPI } from '../../contexts/APIContext'
|
|
||||||
import { useStyles } from '../../contexts/StylesContext'
|
|
||||||
import { useDjangoFormCore } from 'mizan'
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
'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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useDjangoFormCore } from 'mizan'
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
// 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'
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
'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} />}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
'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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
'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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
'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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
'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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { LoginView } from './LoginView'
|
|
||||||
export { SignupView } from './SignupView'
|
|
||||||
export { MFAChooserView } from './MFAChooserView'
|
|
||||||
export { MFAWebAuthnView } from './MFAWebAuthnView'
|
|
||||||
export { MFATOTPView } from './MFATOTPView'
|
|
||||||
export { MFARecoveryCodesView } from './MFARecoveryCodesView'
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 mizan/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
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
|
||||||
import { useAuthContext } from './AuthContext'
|
|
||||||
import { createAPI, AllauthAPI, BrowserFormAction } from '../api'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Browser form action for OAuth redirects.
|
|
||||||
* Creates and submits a form programmatically.
|
|
||||||
*/
|
|
||||||
const browserFormAction: BrowserFormAction = (action: string, data: Record<string, string>) => {
|
|
||||||
const form = document.createElement('form')
|
|
||||||
form.method = 'POST'
|
|
||||||
form.action = action
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.type = 'hidden'
|
|
||||||
input.name = key
|
|
||||||
input.value = value
|
|
||||||
form.appendChild(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.body.appendChild(form)
|
|
||||||
form.submit()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook that returns the Allauth API with automatic auth refresh on relevant responses.
|
|
||||||
*
|
|
||||||
* Automatically triggers auth refresh when:
|
|
||||||
* - 401 with flows (authentication required)
|
|
||||||
* - 410 (session gone)
|
|
||||||
* - 200 with is_authenticated (successful auth)
|
|
||||||
*/
|
|
||||||
export function useAllauthAPI(): AllauthAPI {
|
|
||||||
const client = useDjangoCSRClient(Auth.SESSION)
|
|
||||||
const { refresh } = useAuthContext()
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
|
|
||||||
const resp = await client.request(method, `/_allauth/browser/v1${path}`, data, headers)
|
|
||||||
|
|
||||||
if (resp.status >= 500) {
|
|
||||||
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await resp.json()
|
|
||||||
} catch {
|
|
||||||
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return createAPI(
|
|
||||||
async (method, path, data?, headers?) => {
|
|
||||||
const resp = await authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
|
|
||||||
|
|
||||||
// Auto-refresh auth state on relevant responses
|
|
||||||
if (resp.status === 401 && resp.data?.flows) {
|
|
||||||
refresh(resp)
|
|
||||||
} else if ([401, 410].includes(resp.status) || (resp.status === 200 && resp.meta?.is_authenticated)) {
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
},
|
|
||||||
browserFormAction
|
|
||||||
)
|
|
||||||
}, [client, refresh])
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { ReactNode, useEffect, useState } from 'react'
|
|
||||||
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
|
||||||
import type { RouterAdapter } from '../adapters/router'
|
|
||||||
import type { InitialAuth } from '../hydration'
|
|
||||||
import { AuthContext } from './AuthContext'
|
|
||||||
import { ConfigContext } from './ConfigContext'
|
|
||||||
import { StylesContext } from './StylesContext'
|
|
||||||
import { RouterContext } from './RouterContext'
|
|
||||||
import { AllauthConfig } from '../config'
|
|
||||||
import { AuthClassNames } from '../styles/types'
|
|
||||||
import { createAPI } from '../api'
|
|
||||||
|
|
||||||
export interface AllauthContextProps {
|
|
||||||
children: ReactNode
|
|
||||||
|
|
||||||
/** Router adapter for navigation */
|
|
||||||
router: RouterAdapter
|
|
||||||
|
|
||||||
/** Optional initial auth state from getInitialAuth() - if not provided, fetches client-side */
|
|
||||||
hydration?: InitialAuth
|
|
||||||
|
|
||||||
/** Library configuration (basePath, routes) */
|
|
||||||
allauthConfig?: Partial<AllauthConfig>
|
|
||||||
|
|
||||||
/** CSS class names for styling components */
|
|
||||||
classNames?: AuthClassNames
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Core AllauthContext - sets up all contexts for the allauth library.
|
|
||||||
*
|
|
||||||
* IMPORTANT: AllauthContext must be wrapped by DjangoContext, which provides
|
|
||||||
* user data via useUser(). The typical setup is:
|
|
||||||
*
|
|
||||||
* ```tsx
|
|
||||||
* <DjangoContext client={client} hydration={djangoHydration}>
|
|
||||||
* <AllauthContext hydration={allauthHydration}>
|
|
||||||
* {children}
|
|
||||||
* </AllauthContext>
|
|
||||||
* </DjangoContext>
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* If hydration is provided (from SSR), uses it immediately.
|
|
||||||
* If not provided, fetches initial auth state client-side using the CSR client.
|
|
||||||
*
|
|
||||||
* For Next.js apps, use NextAllauthContext instead which handles the router automatically.
|
|
||||||
*/
|
|
||||||
export function AllauthContext({
|
|
||||||
children,
|
|
||||||
router,
|
|
||||||
hydration,
|
|
||||||
allauthConfig,
|
|
||||||
classNames,
|
|
||||||
}: AllauthContextProps) {
|
|
||||||
const client = useDjangoCSRClient(Auth.SESSION)
|
|
||||||
const [initialAuth, setInitialAuth] = useState<InitialAuth | null>(hydration ?? null)
|
|
||||||
const [loading, setLoading] = useState(!hydration)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hydration) return // Already have SSR hydration
|
|
||||||
|
|
||||||
const fetchInitialAuth = async () => {
|
|
||||||
try {
|
|
||||||
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
|
|
||||||
const resp = await client.request(method, `/_allauth/browser/v1${path}`, data, headers)
|
|
||||||
if (resp.status >= 500) {
|
|
||||||
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
|
|
||||||
}
|
|
||||||
return resp.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = createAPI((method, path, data?, headers?) =>
|
|
||||||
authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
|
|
||||||
)
|
|
||||||
|
|
||||||
const [config, auth] = await Promise.all([
|
|
||||||
api.getConfig(),
|
|
||||||
api.session.getStatus(),
|
|
||||||
])
|
|
||||||
|
|
||||||
setInitialAuth({ config, auth })
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[AllauthContext] Failed to fetch initial auth:', e)
|
|
||||||
setInitialAuth({
|
|
||||||
config: { status: 200, data: {} },
|
|
||||||
auth: { status: 401, data: {} },
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchInitialAuth()
|
|
||||||
}, [client, hydration])
|
|
||||||
|
|
||||||
if (loading || !initialAuth) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RouterContext router={router}>
|
|
||||||
<ConfigContext config={allauthConfig}>
|
|
||||||
<StylesContext classNames={classNames}>
|
|
||||||
<AuthContext
|
|
||||||
config={initialAuth.config}
|
|
||||||
auth={initialAuth.auth}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AuthContext>
|
|
||||||
</StylesContext>
|
|
||||||
</ConfigContext>
|
|
||||||
</RouterContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { useDjangoCSRClient, Auth } from 'mizan/client/react'
|
|
||||||
import { useMizan, useMizanContext } from 'mizan'
|
|
||||||
import { getAuthDetails, createAPI } from '../api'
|
|
||||||
import type { AllauthResponse } from '../types'
|
|
||||||
import getAuthChangeEvent from '../events'
|
|
||||||
|
|
||||||
export interface AuthState {
|
|
||||||
config: AllauthResponse
|
|
||||||
auth: AllauthResponse
|
|
||||||
event: string
|
|
||||||
refresh: (newAuth?: AllauthResponse) => Promise<AllauthResponse>
|
|
||||||
}
|
|
||||||
|
|
||||||
const Context = createContext<AuthState | null>(null)
|
|
||||||
|
|
||||||
export interface AuthContextProps {
|
|
||||||
children: ReactNode
|
|
||||||
/** Initial config from hydration */
|
|
||||||
config: AllauthResponse
|
|
||||||
/** Initial auth from hydration */
|
|
||||||
auth: AllauthResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthContext({
|
|
||||||
children,
|
|
||||||
config,
|
|
||||||
auth: initialAuth,
|
|
||||||
}: AuthContextProps) {
|
|
||||||
const client = useDjangoCSRClient(Auth.SESSION)
|
|
||||||
const { refreshAllContexts } = useMizan()
|
|
||||||
const [auth, setAuth] = useState(initialAuth)
|
|
||||||
const [event, setEvent] = useState('')
|
|
||||||
const prevAuth = useRef(initialAuth)
|
|
||||||
|
|
||||||
// Create API for refresh operations
|
|
||||||
const baseAPI = useMemo(() => {
|
|
||||||
const authRequest = async (method: string, path: string, data?: any, headers?: Record<string, string>) => {
|
|
||||||
const resp = await client.request(method, `/_allauth/browser/v1${path}`, data, headers)
|
|
||||||
if (resp.status >= 500) {
|
|
||||||
throw new Error(`Allauth request failed: ${resp.status} ${resp.statusText}`)
|
|
||||||
}
|
|
||||||
return resp.json()
|
|
||||||
}
|
|
||||||
return createAPI((method, path, data?, headers?) =>
|
|
||||||
authRequest(method, path, { ...(data as object), client: 'browser' }, headers)
|
|
||||||
)
|
|
||||||
}, [client])
|
|
||||||
|
|
||||||
const refresh = useCallback(async (newAuth?: AllauthResponse): Promise<AllauthResponse> => {
|
|
||||||
const authState = newAuth ?? await baseAPI.session.getStatus()
|
|
||||||
setAuth(authState)
|
|
||||||
|
|
||||||
// Refresh all Django contexts (user data, permissions, etc.)
|
|
||||||
await refreshAllContexts()
|
|
||||||
|
|
||||||
return authState
|
|
||||||
}, [baseAPI, refreshAllContexts])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevAuth.current && auth) {
|
|
||||||
setEvent(getAuthChangeEvent(prevAuth.current, auth))
|
|
||||||
}
|
|
||||||
prevAuth.current = auth
|
|
||||||
}, [auth])
|
|
||||||
|
|
||||||
const contextValue = useMemo(() => ({
|
|
||||||
config, auth, event, refresh
|
|
||||||
}), [config, auth, event, refresh])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Context value={contextValue}>
|
|
||||||
{children}
|
|
||||||
</Context>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuthContext(): AuthState {
|
|
||||||
const ctx = useContext(Context)
|
|
||||||
if (!ctx) throw new Error('useAuthContext must be used within AuthContext')
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
return getAuthDetails(useAuthContext().auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base user interface expected by Allauth.
|
|
||||||
* Products can extend this with additional fields.
|
|
||||||
*/
|
|
||||||
export interface AllauthUser {
|
|
||||||
email?: string
|
|
||||||
first_name?: string
|
|
||||||
last_name?: string
|
|
||||||
is_staff?: boolean
|
|
||||||
is_superuser?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current user from MizanProvider.
|
|
||||||
*
|
|
||||||
* This uses the generic mizan hook to access the 'user' context.
|
|
||||||
* The backend defines this context in lib/mizan/allauth/contexts.py:
|
|
||||||
*
|
|
||||||
* @client(context='global')
|
|
||||||
* def user(request) -> UserOutput | None:
|
|
||||||
* ...
|
|
||||||
*
|
|
||||||
* @typeParam T - User type (defaults to AllauthUser, products can use more specific types)
|
|
||||||
*/
|
|
||||||
export function useUser<T extends AllauthUser = AllauthUser>(): T {
|
|
||||||
const user = useMizanContext<T>('user')
|
|
||||||
// Return empty object cast to T if user is undefined (not loaded)
|
|
||||||
// This matches the previous behavior and allows optional chaining
|
|
||||||
return (user ?? {}) as T
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConfig() {
|
|
||||||
return useAuthContext().config
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to access backend feature flags from the allauth configuration.
|
|
||||||
*/
|
|
||||||
export function useFeatures() {
|
|
||||||
const config = useConfig()
|
|
||||||
const data = config?.data as {
|
|
||||||
account?: {
|
|
||||||
is_open_for_signup?: boolean
|
|
||||||
login_by_code_enabled?: boolean
|
|
||||||
email_verification_by_code_enabled?: boolean
|
|
||||||
}
|
|
||||||
mfa?: {
|
|
||||||
supported_types?: string[]
|
|
||||||
}
|
|
||||||
socialaccount?: {
|
|
||||||
providers?: any[]
|
|
||||||
}
|
|
||||||
} | undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
signupEnabled: data?.account?.is_open_for_signup ?? true,
|
|
||||||
loginByCodeEnabled: data?.account?.login_by_code_enabled ?? false,
|
|
||||||
emailVerificationByCodeEnabled: data?.account?.email_verification_by_code_enabled ?? false,
|
|
||||||
mfaEnabled: (data?.mfa?.supported_types?.length ?? 0) > 0,
|
|
||||||
mfaTypes: data?.mfa?.supported_types ?? [],
|
|
||||||
socialLoginEnabled: (data?.socialaccount?.providers?.length ?? 0) > 0,
|
|
||||||
socialProviders: data?.socialaccount?.providers ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
'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)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
'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
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
'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(' ')
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
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'
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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 ''
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { DjangoHTTPClient } from 'mizan/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: {} },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
/**
|
|
||||||
* mizan/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 'mizan/client'
|
|
||||||
* import { getInitialAuth } from 'mizan/allauth'
|
|
||||||
* import { NextAllauthContext } from 'mizan/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'
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next.js adapter for mizan/allauth.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```tsx
|
|
||||||
* // In layout.tsx (server component)
|
|
||||||
* import { createDjangoSSRClient } from 'mizan/client'
|
|
||||||
* import { getInitialAuth } from 'mizan/allauth'
|
|
||||||
* import { NextAllauthContext } from 'mizan/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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
'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
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 = {}
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next.js adapter for mizan/jwt.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```tsx
|
|
||||||
* // In layout.tsx
|
|
||||||
* import { NextAuthContext } from 'mizan/jwt/nextjs'
|
|
||||||
*
|
|
||||||
* export default function RootLayout({ children }) {
|
|
||||||
* return (
|
|
||||||
* <NextAuthContext user={user}>
|
|
||||||
* {children}
|
|
||||||
* </NextAuthContext>
|
|
||||||
* )
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type ReactNode } from 'react'
|
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
|
||||||
import type { RouterAdapter } from './RouterContext'
|
|
||||||
import { RouterContext } from './RouterContext'
|
|
||||||
import { AuthContext, type AuthContextProps } from './AuthContext'
|
|
||||||
import type { BaseUser, AuthRoutes } from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a RouterAdapter from Next.js App Router hooks.
|
|
||||||
*/
|
|
||||||
export function useNextRouter(): RouterAdapter {
|
|
||||||
const router = useRouter()
|
|
||||||
const pathname = usePathname()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
return {
|
|
||||||
push: (path: string) => router.push(path),
|
|
||||||
replace: (path: string) => router.replace(path),
|
|
||||||
pathname,
|
|
||||||
searchParams: new URLSearchParams(searchParams.toString()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NextAuthContextProps<TUser extends BaseUser = BaseUser> {
|
|
||||||
children: ReactNode
|
|
||||||
/** Initial user from SSR hydration */
|
|
||||||
user?: TUser | null
|
|
||||||
/** API endpoint to fetch user data (default: '/api/auth/me/') */
|
|
||||||
userEndpoint?: string
|
|
||||||
/** Route configuration for guards */
|
|
||||||
routes?: Partial<AuthRoutes>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Next.js-specific AuthContext that handles the router automatically.
|
|
||||||
*/
|
|
||||||
export function NextAuthContext<TUser extends BaseUser = BaseUser>({
|
|
||||||
children,
|
|
||||||
user,
|
|
||||||
userEndpoint,
|
|
||||||
routes,
|
|
||||||
}: NextAuthContextProps<TUser>) {
|
|
||||||
const router = useNextRouter()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RouterContext router={router}>
|
|
||||||
<AuthContext user={user} userEndpoint={userEndpoint} routes={routes}>
|
|
||||||
{children}
|
|
||||||
</AuthContext>
|
|
||||||
</RouterContext>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
'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
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user