WIP: M3 PWA/offline implementation, M4 auth (backend + frontend), D-013 faster-whisper
- M3: hand-rolled service worker (precache + runtime /ort caching), precache manifest injection, SW registration/update toast, manifest + generated icons, production build script, offline indicator in ModelPicker (D-005/D-007) - M4 backend: bun:sqlite users/sessions (D-009), Passport local strategy behind connect-compat adapter (D-008), /api/auth routes wired into dev+prod serving, Bun.password Argon2id, 38 auth tests - M4 frontend: AuthForms + useAuth + framework-free authClient, session-aware header, anonymous-first fail-silent offline handling, 18 client tests - D-013: server engine (M5) will use faster-whisper via Python sidecar; browser side stays Transformers.js (CTranslate2 has no WASM build) - bun test: 83 pass / tsc --noEmit clean - Note: M3 doc closeout (WorkBreakdown/Architecture/tests/offline.md) still in flight from a background agent; follow-up commit will finish docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb72a2e646
commit
9f39a82982
38 files changed
+3857
-246
No files matched your search
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Auth API client (M4). Deliberately framework-free: validation is pure and
|
||||
* the fetch layer is injectable, so `bun test` can exercise everything without
|
||||
* a DOM or a running server (tests/auth-client.test.ts). React state lives in
|
||||
* `useAuth` (src/lib/useAuth.ts).
|
||||
*
|
||||
* Contract (mirrored by src/server): all endpoints exchange JSON and errors
|
||||
* arrive as `{ error: string }`. The session cookie is HttpOnly, so
|
||||
* `GET /api/auth/me` is the ONLY way to learn the current auth state.
|
||||
*/
|
||||
|
||||
export interface AuthUser {
|
||||
id: number | string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/** Minimal fetch signature so tests can inject a mock. */
|
||||
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
// Bound lazily via globalThis: bare `fetch` references passed around as values
|
||||
// can throw "Illegal invocation" in some browsers.
|
||||
const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
|
||||
|
||||
// --- Client-side validation ---------------------------------------------
|
||||
// Mirrors the server rules for instant feedback; the server stays
|
||||
// authoritative and its `{ error }` messages are surfaced on 4xx regardless.
|
||||
|
||||
export const USERNAME_MIN_LENGTH = 3;
|
||||
export const USERNAME_MAX_LENGTH = 32;
|
||||
export const USERNAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||
export const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
/** Returns a human-readable problem with the username, or null when valid. */
|
||||
export function validateUsername(username: string): string | null {
|
||||
if (
|
||||
username.length < USERNAME_MIN_LENGTH ||
|
||||
username.length > USERNAME_MAX_LENGTH
|
||||
) {
|
||||
return `Username must be ${USERNAME_MIN_LENGTH}–${USERNAME_MAX_LENGTH} characters.`;
|
||||
}
|
||||
if (!USERNAME_PATTERN.test(username)) {
|
||||
return "Username may only contain letters, digits, and _ . -";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns a human-readable problem with the password, or null when valid. */
|
||||
export function validatePassword(password: string): string | null {
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- API requests ---------------------------------------------------------
|
||||
|
||||
function isAuthUser(value: unknown): value is AuthUser {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as { id?: unknown; username?: unknown };
|
||||
return (
|
||||
(typeof candidate.id === "number" || typeof candidate.id === "string") &&
|
||||
typeof candidate.username === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/** Pull `{ error }` out of a 4xx/5xx body, falling back to a generic line. */
|
||||
async function readErrorMessage(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
if (
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
typeof (body as { error?: unknown }).error === "string"
|
||||
) {
|
||||
return (body as { error: string }).error;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON error body (proxy page, empty 500) — use the fallback.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST credentials to an auth endpoint and return the signed-in user.
|
||||
* Throws an `Error` whose message is safe to show in the UI.
|
||||
*/
|
||||
async function requestUser(
|
||||
fetchFn: FetchLike,
|
||||
path: string,
|
||||
credentials: { username: string; password: string },
|
||||
): Promise<AuthUser> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
} catch {
|
||||
throw new Error("Could not reach the server. Check your connection.");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
await readErrorMessage(response, `Request failed (${response.status}).`),
|
||||
);
|
||||
}
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const user = (payload as { user?: unknown } | null)?.user;
|
||||
if (!isAuthUser(user)) {
|
||||
throw new Error("The server returned an unexpected response.");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** POST /api/auth/login → user, or throws with the server's error message. */
|
||||
export function login(
|
||||
username: string,
|
||||
password: string,
|
||||
fetchFn: FetchLike = defaultFetch,
|
||||
): Promise<AuthUser> {
|
||||
return requestUser(fetchFn, "/api/auth/login", { username, password });
|
||||
}
|
||||
|
||||
/** POST /api/auth/register → user (auto signed in), or throws (400/409). */
|
||||
export function register(
|
||||
username: string,
|
||||
password: string,
|
||||
fetchFn: FetchLike = defaultFetch,
|
||||
): Promise<AuthUser> {
|
||||
return requestUser(fetchFn, "/api/auth/register", { username, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/me → the current user, or null when anonymous. NEVER rejects:
|
||||
* the app is offline-first and /api/* is network-only in the service worker,
|
||||
* so 401s, network failures, and malformed bodies all resolve to anonymous
|
||||
* without surfacing an error (Architecture.md §5 anonymous-first invariant).
|
||||
*/
|
||||
export async function fetchCurrentUser(
|
||||
fetchFn: FetchLike = defaultFetch,
|
||||
): Promise<AuthUser | null> {
|
||||
try {
|
||||
const response = await fetchFn("/api/auth/me", {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload: unknown = await response.json();
|
||||
const user = (payload as { user?: unknown } | null)?.user;
|
||||
return isAuthUser(user) ? user : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout (→ 204). Swallows failures: the caller clears local
|
||||
* state either way, and an unreachable server just means the cookie expires
|
||||
* on its own.
|
||||
*/
|
||||
export async function logout(fetchFn: FetchLike = defaultFetch): Promise<void> {
|
||||
try {
|
||||
await fetchFn("/api/auth/logout", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch {
|
||||
// Offline — nothing to do.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* "Is this model already downloaded?" helper (Decisions.md D-005).
|
||||
*
|
||||
* Transformers.js v3 persists Hub downloads in the browser Cache API (Cache
|
||||
* Storage, cache name "transformers-cache"). We inspect the cached request
|
||||
* URLs and match them against catalog model ids. Used by `ModelPicker` for the
|
||||
* downloaded badge (M2) and reused by the offline indicator (M3).
|
||||
*
|
||||
* `matchCachedModels` is pure so it can be unit-tested; only
|
||||
* `getDownloadedModels` touches the `caches` global.
|
||||
*/
|
||||
|
||||
/** Cache name used by Transformers.js v3 (`env.cacheName` default). */
|
||||
export const TRANSFORMERS_CACHE_NAME = "transformers-cache";
|
||||
|
||||
/**
|
||||
* Pure matcher: which of `modelIds` have at least one cached ONNX weight file?
|
||||
*
|
||||
* Cached URLs look like
|
||||
* `https://huggingface.co/<modelId>/resolve/main/onnx/<file>.onnx`. We require
|
||||
* an `.onnx` hit (not just tokenizer/config JSON) so a model whose weights
|
||||
* never finished downloading does not show as available.
|
||||
*/
|
||||
export function matchCachedModels(
|
||||
urls: Iterable<string>,
|
||||
modelIds: readonly string[],
|
||||
): Set<string> {
|
||||
const downloaded = new Set<string>();
|
||||
for (const url of urls) {
|
||||
if (!url.split("?")[0]?.endsWith(".onnx")) continue;
|
||||
for (const modelId of modelIds) {
|
||||
if (!downloaded.has(modelId) && url.includes(`/${modelId}/`)) {
|
||||
downloaded.add(modelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the browser Cache API for models with cached weights. Returns an
|
||||
* empty set where Cache Storage is unavailable (non-secure contexts, tests).
|
||||
*/
|
||||
export async function getDownloadedModels(
|
||||
modelIds: readonly string[],
|
||||
): Promise<Set<string>> {
|
||||
if (typeof caches === "undefined") return new Set();
|
||||
try {
|
||||
const urls: string[] = [];
|
||||
// Match any transformers-owned cache, in case the name is ever versioned.
|
||||
for (const name of await caches.keys()) {
|
||||
if (!name.includes("transformers")) continue;
|
||||
const cache = await caches.open(name);
|
||||
for (const request of await cache.keys()) urls.push(request.url);
|
||||
}
|
||||
return matchCachedModels(urls, modelIds);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
+260
-12
@@ -1,20 +1,268 @@
|
||||
/**
|
||||
* Model catalog (Decisions.md D-006).
|
||||
* Curated Whisper model catalog (Decisions.md D-006).
|
||||
*
|
||||
* M1 ships a single hardcoded model. The curated catalog — tiny → large,
|
||||
* English-only vs multilingual, dtype defaults per device, size estimates —
|
||||
* lands in M2.
|
||||
* Pure, typed data — no browser globals — so it is unit-testable with
|
||||
* `bun test` and shareable with the server engine (M5). ONNX conversions come
|
||||
* from the `onnx-community` Hub org (the recommended source for
|
||||
* Transformers.js v3+).
|
||||
*
|
||||
* TODO(M2): curated `MODELS` catalog with sizes/dtypes/languages (D-006).
|
||||
* dtype defaults per device follow the community-known-good combinations:
|
||||
* - WASM: q8 for small models, q4 for the large ones (download size).
|
||||
* - WebGPU: fp16 encoder + q4 merged decoder for the big models; fp32 for
|
||||
* tiny/base where fp16 has known accuracy issues.
|
||||
* `verified` is only true for combinations we have actually run in a browser;
|
||||
* the rest are pending manual testing (WorkBreakdown M2).
|
||||
*/
|
||||
|
||||
import type { InferenceDevice } from "../worker/protocol";
|
||||
import type {
|
||||
DevicePreference,
|
||||
DtypeSpec,
|
||||
InferenceDevice,
|
||||
WhisperDtype,
|
||||
WhisperTask,
|
||||
} from "../worker/protocol";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dtype default for one device, with verification status (D-006). */
|
||||
export interface DtypeDefault {
|
||||
dtype: DtypeSpec;
|
||||
/** True only if this exact combination has been run in a real browser. */
|
||||
verified: boolean;
|
||||
/** Why this combination was chosen / caveats. */
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface WhisperModel {
|
||||
/** Hugging Face Hub id, e.g. "onnx-community/whisper-tiny.en". */
|
||||
id: string;
|
||||
/** Human-readable name for the picker. */
|
||||
displayName: string;
|
||||
/** False for `.en` (English-only) variants. */
|
||||
multilingual: boolean;
|
||||
/** Encoder parameter count, in millions (drives size estimates). */
|
||||
encoderParamsM: number;
|
||||
/** Decoder parameter count, in millions (drives size estimates). */
|
||||
decoderParamsM: number;
|
||||
/** Per-device dtype defaults (D-004 / D-006). */
|
||||
defaults: Record<InferenceDevice, DtypeDefault>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size estimation (approximate, shown before download — D-006)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Approximate stored bytes per parameter for each quantization level. */
|
||||
export const BYTES_PER_PARAM: Record<WhisperDtype, number> = {
|
||||
fp32: 4,
|
||||
fp16: 2,
|
||||
q8: 1.05, // int8 weights + scales/zero-points overhead
|
||||
q4: 0.6, // 4-bit blocks + per-block scales; some tensors stay higher precision
|
||||
};
|
||||
|
||||
/** Tokenizer + configs + preprocessor, roughly constant per model. */
|
||||
const AUX_FILES_BYTES = 3_000_000;
|
||||
|
||||
/** Approximate total download size for a model at a given dtype spec. */
|
||||
export function estimateDownloadBytes(
|
||||
model: WhisperModel,
|
||||
dtype: DtypeSpec,
|
||||
): number {
|
||||
const encDtype = typeof dtype === "string" ? dtype : dtype.encoder_model;
|
||||
const decDtype = typeof dtype === "string" ? dtype : dtype.decoder_model_merged;
|
||||
return Math.round(
|
||||
model.encoderParamsM * 1e6 * BYTES_PER_PARAM[encDtype] +
|
||||
model.decoderParamsM * 1e6 * BYTES_PER_PARAM[decDtype] +
|
||||
AUX_FILES_BYTES,
|
||||
);
|
||||
}
|
||||
|
||||
/** "487 MB" / "1.6 GB" style formatting for size estimates. */
|
||||
export function formatModelSize(bytes: number): string {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
return `${Math.round(bytes / 1e6)} MB`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dtype defaults (community-known-good; see file header)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WASM_Q8: DtypeDefault = {
|
||||
dtype: "q8",
|
||||
verified: false,
|
||||
note: "q8 is the Transformers.js WASM default; good quality/size trade-off.",
|
||||
};
|
||||
|
||||
const WASM_Q4: DtypeDefault = {
|
||||
dtype: "q4",
|
||||
verified: false,
|
||||
note: "q4 keeps the download manageable for larger models on WASM.",
|
||||
};
|
||||
|
||||
/** fp16 has known accuracy issues for the small Whisper encoders on WebGPU. */
|
||||
const WEBGPU_FP32: DtypeDefault = {
|
||||
dtype: { encoder_model: "fp32", decoder_model_merged: "fp32" },
|
||||
verified: false,
|
||||
note: "fp32 both parts: fp16 encoders are known-bad for tiny/base on WebGPU.",
|
||||
};
|
||||
|
||||
const WEBGPU_FP16_Q4: DtypeDefault = {
|
||||
dtype: { encoder_model: "fp16", decoder_model_merged: "q4" },
|
||||
verified: false,
|
||||
note: "fp16 encoder + q4 merged decoder — the common working WebGPU setup.",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The catalog (D-006: curated, not exhaustive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const MODELS: readonly WhisperModel[] = [
|
||||
{
|
||||
id: "onnx-community/whisper-tiny.en",
|
||||
displayName: "Whisper Tiny (English)",
|
||||
multilingual: false,
|
||||
encoderParamsM: 8,
|
||||
decoderParamsM: 31,
|
||||
defaults: {
|
||||
// Verified in-browser during M1 (WASM, Transformers.js default q8).
|
||||
wasm: { ...WASM_Q8, verified: true },
|
||||
webgpu: WEBGPU_FP32,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-tiny",
|
||||
displayName: "Whisper Tiny",
|
||||
multilingual: true,
|
||||
encoderParamsM: 8,
|
||||
decoderParamsM: 31,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-base.en",
|
||||
displayName: "Whisper Base (English)",
|
||||
multilingual: false,
|
||||
encoderParamsM: 20,
|
||||
decoderParamsM: 54,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-base",
|
||||
displayName: "Whisper Base",
|
||||
multilingual: true,
|
||||
encoderParamsM: 20,
|
||||
decoderParamsM: 54,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-small.en",
|
||||
displayName: "Whisper Small (English)",
|
||||
multilingual: false,
|
||||
encoderParamsM: 88,
|
||||
decoderParamsM: 156,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-small",
|
||||
displayName: "Whisper Small",
|
||||
multilingual: true,
|
||||
encoderParamsM: 88,
|
||||
decoderParamsM: 156,
|
||||
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-medium.en",
|
||||
displayName: "Whisper Medium (English)",
|
||||
multilingual: false,
|
||||
encoderParamsM: 306,
|
||||
decoderParamsM: 463,
|
||||
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-medium",
|
||||
displayName: "Whisper Medium",
|
||||
multilingual: true,
|
||||
encoderParamsM: 306,
|
||||
decoderParamsM: 463,
|
||||
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
{
|
||||
id: "onnx-community/whisper-large-v3-turbo",
|
||||
displayName: "Whisper Large v3 Turbo",
|
||||
multilingual: true,
|
||||
encoderParamsM: 635,
|
||||
decoderParamsM: 174,
|
||||
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
|
||||
},
|
||||
];
|
||||
|
||||
export function getModel(id: string): WhisperModel | undefined {
|
||||
return MODELS.find((model) => model.id === id);
|
||||
}
|
||||
|
||||
/** Default dtype for a model on a given device. Unknown ids fall back to q8. */
|
||||
export function resolveDefaultDtype(
|
||||
modelId: string,
|
||||
device: InferenceDevice,
|
||||
): DtypeSpec {
|
||||
return getModel(modelId)?.defaults[device].dtype ?? "q8";
|
||||
}
|
||||
|
||||
/** Approximate download size using the model's default dtype for `device`. */
|
||||
export function estimateDefaultDownloadBytes(
|
||||
model: WhisperModel,
|
||||
device: InferenceDevice,
|
||||
): number {
|
||||
return estimateDownloadBytes(model, model.defaults[device].dtype);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults & language list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The one model M1 supports. `onnx-community` is the recommended org for
|
||||
* Transformers.js v3+ ONNX conversions.
|
||||
*/
|
||||
export const DEFAULT_MODEL_ID = "onnx-community/whisper-tiny.en";
|
||||
|
||||
/** M1 runs WASM only; WebGPU detection arrives in M2 (D-004). */
|
||||
export const DEFAULT_DEVICE: InferenceDevice = "wasm";
|
||||
/** UI default: let the worker probe WebGPU and fall back to WASM (D-004). */
|
||||
export const DEFAULT_DEVICE_PREFERENCE: DevicePreference = "auto";
|
||||
|
||||
export const DEFAULT_TASK: WhisperTask = "transcribe";
|
||||
|
||||
/** Sentinel for "let Whisper detect the language". */
|
||||
export const AUTO_LANGUAGE = "auto";
|
||||
|
||||
/**
|
||||
* Curated language list for multilingual models (Whisper language codes).
|
||||
* Not exhaustive — Whisper supports ~100; extend on demand.
|
||||
*/
|
||||
export const WHISPER_LANGUAGES: readonly { code: string; label: string }[] = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "da", label: "Danish" },
|
||||
{ code: "de", label: "German" },
|
||||
{ code: "es", label: "Spanish" },
|
||||
{ code: "fr", label: "French" },
|
||||
{ code: "it", label: "Italian" },
|
||||
{ code: "nl", label: "Dutch" },
|
||||
{ code: "no", label: "Norwegian" },
|
||||
{ code: "sv", label: "Swedish" },
|
||||
{ code: "fi", label: "Finnish" },
|
||||
{ code: "is", label: "Icelandic" },
|
||||
{ code: "pl", label: "Polish" },
|
||||
{ code: "pt", label: "Portuguese" },
|
||||
{ code: "ro", label: "Romanian" },
|
||||
{ code: "cs", label: "Czech" },
|
||||
{ code: "el", label: "Greek" },
|
||||
{ code: "hu", label: "Hungarian" },
|
||||
{ code: "uk", label: "Ukrainian" },
|
||||
{ code: "ru", label: "Russian" },
|
||||
{ code: "tr", label: "Turkish" },
|
||||
{ code: "ar", label: "Arabic" },
|
||||
{ code: "he", label: "Hebrew" },
|
||||
{ code: "hi", label: "Hindi" },
|
||||
{ code: "id", label: "Indonesian" },
|
||||
{ code: "vi", label: "Vietnamese" },
|
||||
{ code: "th", label: "Thai" },
|
||||
{ code: "zh", label: "Chinese" },
|
||||
{ code: "ja", label: "Japanese" },
|
||||
{ code: "ko", label: "Korean" },
|
||||
];
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
fetchCurrentUser,
|
||||
login as loginRequest,
|
||||
logout as logoutRequest,
|
||||
register as registerRequest,
|
||||
type AuthUser,
|
||||
} from "./authClient";
|
||||
|
||||
export interface AuthState {
|
||||
/** The signed-in user, or null while anonymous (the default). */
|
||||
user: AuthUser | null;
|
||||
/** Rejects with a UI-safe Error message on failure (e.g. 401). */
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
/** Rejects with a UI-safe Error message on failure (400/409). */
|
||||
register: (username: string, password: string) => Promise<void>;
|
||||
/** Always resolves; local state is cleared even if the server is down. */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session state for the M4 auth UI. The session cookie is HttpOnly, so the
|
||||
* only source of truth is GET /api/auth/me, probed once on mount.
|
||||
* `fetchCurrentUser` never rejects — 401 and network failure (offline, server
|
||||
* down) both resolve to anonymous, keeping the anonymous-first invariant:
|
||||
* a failing /me must not surface any error.
|
||||
*/
|
||||
export function useAuth(): AuthState {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchCurrentUser().then((current) => {
|
||||
// Guard against StrictMode double-mounts / fast unmounts.
|
||||
if (!cancelled && current) setUser(current);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
setUser(await loginRequest(username, password));
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (username: string, password: string) => {
|
||||
// Register signs the user in immediately (the 201 sets the cookie).
|
||||
setUser(await registerRequest(username, password));
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutRequest();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return { user, login, register, logout };
|
||||
}
|
||||
Reference in new issue
Block a user