From 054371c5789fc392b0afdddb72d91916df0879b4 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 13:42:22 +0200 Subject: [PATCH 01/16] background_tasks pages: typed API client for workers, outbox, logs, retry (#504) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../background_tasks/pages/Workers.tsx | 31 +- .../background_tasks/pages/api.generated.ts | 965 ++++++++++++++++++ .../background_tasks/pages/api.ts | 9 + .../pages/components/WorkerHealthBanner.tsx | 23 +- .../background_tasks/pages/logs.ts | 15 +- .../background_tasks/pages/outbox.ts | 55 +- .../background_tasks/pages/retry.ts | 79 +- .../background_tasks_DetailPage.test.tsx | 10 +- .../background_tasks_IndexPage.test.tsx | 18 +- .../background_tasks_LogsTab.test.tsx | 4 +- .../background_tasks_OutboxPage.test.tsx | 18 +- ...ckground_tasks_WorkerHealthBanner.test.tsx | 49 + .../background_tasks/tests-js/outbox.test.ts | 18 +- .../background_tasks/tests-js/stub-fetch.ts | 8 + 14 files changed, 1161 insertions(+), 141 deletions(-) create mode 100644 modules/background_tasks/background_tasks/pages/api.generated.ts create mode 100644 modules/background_tasks/background_tasks/pages/api.ts create mode 100644 modules/background_tasks/tests-js/background_tasks_WorkerHealthBanner.test.tsx diff --git a/modules/background_tasks/background_tasks/pages/Workers.tsx b/modules/background_tasks/background_tasks/pages/Workers.tsx index a04b350c4..d30b63eef 100644 --- a/modules/background_tasks/background_tasks/pages/Workers.tsx +++ b/modules/background_tasks/background_tasks/pages/Workers.tsx @@ -3,14 +3,16 @@ import { keys, useT } from '@simple-module-py/i18n'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; +import { apiResult, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; import { ageOf, isStale } from '@simple-module-py/ui/lib/relative-time'; import { ArrowLeft, RefreshCw } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; +import { api } from './api'; import { RoutesTable } from './components/RoutesTable'; import { WorkerCard } from './components/WorkerCard'; import { BrokerUnreachable, NoWorkers } from './components/WorkerEmptyStates'; -import { API_BASE, formatClock, type TaskRoute, VIEW_BASE, type WorkerSnapshot } from './constants'; +import { formatClock, type TaskRoute, VIEW_BASE, type WorkerSnapshot } from './constants'; interface Props { snapshot: WorkerSnapshot; @@ -42,23 +44,20 @@ function Workers({ snapshot: initial, broker_url_redacted: brokerUrl, queues, ro async function refresh() { setLoading(true); - try { - const res = await fetch(`${API_BASE}/workers`, { - headers: { Accept: 'application/json' }, - }); - if (res.ok) { - setSnapshot((await res.json()) as WorkerSnapshot); - // Don't wait up to a tick for the staleness to catch up with the new - // reading. - setNow(Date.now()); - } else { - toast.error(t(keys.background_tasks.workers.refresh_failed_status, { status: res.status })); - } - } catch { + const result = await apiResult(api.GET('/api/background_tasks/admin/workers')); + if (result.ok) { + setSnapshot(result.data as WorkerSnapshot); + // Don't wait up to a tick for the staleness to catch up with the new + // reading. + setNow(Date.now()); + } else if (result.status === NETWORK_FAILURE) { toast.error(t(keys.background_tasks.workers.refresh_failed)); - } finally { - setLoading(false); + } else { + toast.error( + t(keys.background_tasks.workers.refresh_failed_status, { status: result.status }), + ); } + setLoading(false); } return ( diff --git a/modules/background_tasks/background_tasks/pages/api.generated.ts b/modules/background_tasks/background_tasks/pages/api.generated.ts new file mode 100644 index 000000000..a36f55662 --- /dev/null +++ b/modules/background_tasks/background_tasks/pages/api.generated.ts @@ -0,0 +1,965 @@ +// AUTO-GENERATED by `smpy openapi gen-types` from the BackgroundTasks module's /api +// operations — do not edit. Regenerate with `smpy openapi gen-types --module background_tasks` +// (`make gen-api-types` in the framework repo). +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/background_tasks/admin/executions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Executions + * @description List executions, filtered exactly as the page filters them. + * + * The task-name search is ``q`` — the same name the page and the sweep use, + * so an operator can move a URL between the three and keep their filter. This + * endpoint originally spelled it ``task_name``, which meant a link copied off + * the screen silently listed *everything*; that spelling is still accepted so + * existing API callers keep working, and ``q`` wins when both are given. + */ + get: operations["background_tasks_list_executions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/admin/executions/{execution_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Execution */ + get: operations["background_tasks_get_execution"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/admin/executions/retry-failed": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Retry Failed Executions + * @description Re-enqueue every failed or stuck execution the current filter can see. + * + * Takes the same filters as the listing — status, search, queue, tenant — so + * the sweep covers exactly the rows the operator is looking at and nothing + * else. Capped per call; the response says how many eligible rows are left. + */ + post: operations["background_tasks_retry_failed_executions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/admin/executions/{execution_id}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Retry Execution */ + post: operations["background_tasks_retry_execution"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/admin/workers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Workers + * @description Live snapshot of every Celery worker reachable via the broker. + * + * Goes through ``poll_workers`` so the Refresh button also refreshes the + * snapshot other screens read, rather than leaving them on a stale one this + * request has already superseded. + */ + get: operations["background_tasks_get_workers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/tasks/{celery_task_id}/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Task Status */ + get: operations["background_tasks_get_task_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/admin/outbox": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Outbox + * @description Undelivered durable events (pending, running, dead), newest first. + */ + get: operations["background_tasks_list_outbox"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/admin/outbox/{message_id}/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Retry Outbox Message + * @description Send a dead message round again from attempt 1. + */ + post: operations["background_tasks_retry_outbox_message"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/admin/outbox/{message_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Discard Outbox Message + * @description Drop a pending or dead message without delivering it. + */ + delete: operations["background_tasks_discard_outbox_message"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/admin/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search Logs + * @description Retained log lines, newest first; ``level`` is a minimum. + */ + get: operations["background_tasks_search_logs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/background_tasks/admin/executions/{execution_id}/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Execution Logs + * @description The log lines one execution wrote, found by its Celery task id. + */ + get: operations["background_tasks_execution_logs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** LogItem */ + LogItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** + * Ts + * Format: date-time + */ + ts: string; + /** Level */ + level: number; + /** Level Name */ + level_name: string; + /** Logger */ + logger: string; + /** Message */ + message: string; + /** Task Id */ + task_id?: string | null; + /** Job Id */ + job_id?: string | null; + /** Tenant Id */ + tenant_id?: string | null; + /** Correlation Id */ + correlation_id?: string | null; + /** Extra */ + extra?: { + [key: string]: unknown; + }; + }; + /** LogListResponse */ + LogListResponse: { + /** Items */ + items: components["schemas"]["LogItem"][]; + /** Total */ + total: number; + /** Page */ + page: number; + /** Per Page */ + per_page: number; + }; + /** + * OutboxItem + * @description One (event, durable handler) delivery as the admin screen shows it. + */ + OutboxItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** Event Key */ + event_key: string; + /** Handler Name */ + handler_name: string; + /** + * Payload + * @default {} + */ + payload: { + [key: string]: unknown; + }; + /** Tenant Id */ + tenant_id?: string | null; + status: components["schemas"]["OutboxStatus"]; + /** Attempts */ + attempts: number; + /** + * Next Attempt At + * Format: date-time + */ + next_attempt_at: string; + /** Locked Until */ + locked_until?: string | null; + /** Last Error */ + last_error?: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Delivered At */ + delivered_at?: string | null; + }; + /** OutboxListResponse */ + OutboxListResponse: { + /** Items */ + items: components["schemas"]["OutboxItem"][]; + /** Total */ + total: number; + /** Page */ + page: number; + /** Per Page */ + per_page: number; + /** Counts */ + counts: { + [key: string]: number; + }; + }; + /** + * OutboxStatus + * @description Lifecycle of one (event, durable handler) delivery. + * @enum {string} + */ + OutboxStatus: "pending" | "running" | "delivered" | "dead"; + /** + * RetryFailedResult + * @description What a bulk retry actually did. + * + * The caller cannot compute either number: the endpoint decides which rows + * count as retryable and caps how many it will take in one pass, so both have + * to come back from the action rather than be inferred from what the screen + * was showing. + */ + RetryFailedResult: { + /** Queued */ + queued: number; + /** + * Remaining + * @default 0 + */ + remaining: number; + }; + /** + * TaskExecutionDetail + * @description Full row for the detail page (adds args/kwargs/result/traceback). + */ + TaskExecutionDetail: { + /** + * Id + * Format: uuid + */ + id: string; + /** Celery Task Id */ + celery_task_id?: string | null; + /** Task Name */ + task_name: string; + status: components["schemas"]["TaskStatus"]; + /** Queue */ + queue: string; + /** Tenant Id */ + tenant_id?: string | null; + /** + * Args + * @default [] + */ + args: unknown[]; + /** + * Kwargs + * @default {} + */ + kwargs: { + [key: string]: unknown; + }; + /** Result */ + result?: { + [key: string]: unknown; + } | null; + /** Traceback */ + traceback?: string | null; + /** Exception Type */ + exception_type?: string | null; + /** Worker */ + worker?: string | null; + /** + * Retries + * @default 0 + */ + retries: number; + /** Retried From Id */ + retried_from_id?: string | null; + /** Owner User Id */ + owner_user_id?: string | null; + /** Progress Current */ + progress_current?: number | null; + /** Progress Total */ + progress_total?: number | null; + /** Progress Message */ + progress_message?: string | null; + /** Progress Phase */ + progress_phase?: string | null; + /** Queued At */ + queued_at?: string | null; + /** Started At */ + started_at?: string | null; + /** Finished At */ + finished_at?: string | null; + /** Heartbeat At */ + heartbeat_at?: string | null; + }; + /** + * TaskExecutionListItem + * @description Compact row for the admin listing table. + * + * Carries ``args``/``kwargs`` even though the table never renders them: the + * retry confirm opens straight from a row and has to show the payload it is + * about to re-enqueue. Re-running a bad payload just fails the same way, so + * the operator needs to read it *before* confirming, not on a second page. + */ + TaskExecutionListItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** Celery Task Id */ + celery_task_id?: string | null; + /** Task Name */ + task_name: string; + status: components["schemas"]["TaskStatus"]; + /** Queue */ + queue: string; + /** Tenant Id */ + tenant_id?: string | null; + /** + * Args + * @default [] + */ + args: unknown[]; + /** + * Kwargs + * @default {} + */ + kwargs: { + [key: string]: unknown; + }; + /** Retries */ + retries: number; + /** Worker */ + worker?: string | null; + /** Queued At */ + queued_at?: string | null; + /** Started At */ + started_at?: string | null; + /** Finished At */ + finished_at?: string | null; + /** Exception Type */ + exception_type?: string | null; + /** Retried From Id */ + retried_from_id?: string | null; + }; + /** + * TaskExecutionListResponse + * @description Paginated response shape used by both the API and Inertia views. + */ + TaskExecutionListResponse: { + /** Items */ + items: components["schemas"]["TaskExecutionListItem"][]; + /** Total */ + total: number; + /** Page */ + page: number; + /** Per Page */ + per_page: number; + status?: components["schemas"]["TaskStatus"] | null; + /** Task Name */ + task_name?: string | null; + /** Queue */ + queue?: string | null; + }; + /** + * TaskStatus + * @description Lifecycle state of a single task execution row. + * @enum {string} + */ + TaskStatus: "pending" | "running" | "success" | "failed" | "stuck" | "revoked" | "retrying"; + /** + * TaskStatusDTO + * @description What the owner of a task may see of it (#536). + * + * Deliberately narrower than :class:`TaskExecutionDetail`: no args, kwargs, + * result or traceback, and the error is the exception's *type name* only — + * a message or traceback can carry other users' data or server internals. + */ + TaskStatusDTO: { + /** Celery Task Id */ + celery_task_id: string; + /** Task Name */ + task_name: string; + status: components["schemas"]["TaskStatus"]; + /** Progress Current */ + progress_current?: number | null; + /** Progress Total */ + progress_total?: number | null; + /** Progress Message */ + progress_message?: string | null; + /** Progress Phase */ + progress_phase?: string | null; + /** Exception Type */ + exception_type?: string | null; + /** Queued At */ + queued_at?: string | null; + /** Started At */ + started_at?: string | null; + /** Finished At */ + finished_at?: string | null; + /** Heartbeat At */ + heartbeat_at?: string | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + /** + * WorkerInfo + * @description One Celery worker as reported by ``celery.control.inspect()``. + */ + WorkerInfo: { + /** Hostname */ + hostname: string; + /** Online */ + online: boolean; + /** + * Queues + * @default [] + */ + queues: string[]; + /** + * Active Task Count + * @default 0 + */ + active_task_count: number; + /** Pool Size */ + pool_size?: number | null; + /** Total Processed */ + total_processed?: number | null; + /** Software */ + software?: string | null; + /** Uptime Seconds */ + uptime_seconds?: number | null; + }; + /** + * WorkerSnapshot + * @description Point-in-time picture of every worker known to the broker. + */ + WorkerSnapshot: { + /** Broker Reachable */ + broker_reachable: boolean; + /** + * Polled At + * Format: date-time + */ + polled_at: string; + /** + * Workers + * @default [] + */ + workers: components["schemas"]["WorkerInfo"][]; + /** Error */ + error?: string | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + background_tasks_list_executions: { + parameters: { + query?: { + status?: components["schemas"]["TaskStatus"] | null; + q?: string | null; + /** @deprecated */ + task_name?: string | null; + queue?: string | null; + tenant_id?: string | null; + page?: number; + per_page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskExecutionListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + background_tasks_get_execution: { + parameters: { + query?: never; + header?: never; + path: { + execution_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskExecutionDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + background_tasks_retry_failed_executions: { + parameters: { + query?: { + status?: components["schemas"]["TaskStatus"] | null; + q?: string | null; + queue?: string | null; + tenant_id?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RetryFailedResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + background_tasks_retry_execution: { + parameters: { + query?: never; + header?: never; + path: { + execution_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskExecutionDetail"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + background_tasks_get_workers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkerSnapshot"]; + }; + }; + }; + }; + background_tasks_get_task_status: { + parameters: { + query?: never; + header?: never; + path: { + celery_task_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TaskStatusDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + background_tasks_list_outbox: { + parameters: { + query?: { + status?: components["schemas"]["OutboxStatus"] | null; + page?: number; + per_page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OutboxListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + background_tasks_retry_outbox_message: { + parameters: { + query?: never; + header?: never; + path: { + message_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OutboxItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + background_tasks_discard_outbox_message: { + parameters: { + query?: never; + header?: never; + path: { + message_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + background_tasks_search_logs: { + parameters: { + query?: { + q?: string | null; + level?: string | null; + task_id?: string | null; + logger?: string | null; + page?: number; + per_page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LogListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + background_tasks_execution_logs: { + parameters: { + query?: { + level?: string | null; + page?: number; + per_page?: number; + }; + header?: never; + path: { + execution_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LogListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/modules/background_tasks/background_tasks/pages/api.ts b/modules/background_tasks/background_tasks/pages/api.ts new file mode 100644 index 000000000..f2fb16fb3 --- /dev/null +++ b/modules/background_tasks/background_tasks/pages/api.ts @@ -0,0 +1,9 @@ +/** + * Typed client for this module's `/api` routes (#504). The `paths` types are + * generated from the routes — regenerate with `make gen-api-types` (or + * `smpy openapi gen-types --module background_tasks`), never edit them by hand. + */ +import { createApi } from '@simple-module-py/ui/lib/api'; +import type { paths } from './api.generated'; + +export const api = createApi(); diff --git a/modules/background_tasks/background_tasks/pages/components/WorkerHealthBanner.tsx b/modules/background_tasks/background_tasks/pages/components/WorkerHealthBanner.tsx index 5c355d88b..5843a01e9 100644 --- a/modules/background_tasks/background_tasks/pages/components/WorkerHealthBanner.tsx +++ b/modules/background_tasks/background_tasks/pages/components/WorkerHealthBanner.tsx @@ -2,9 +2,11 @@ import { Link } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner'; import { Button } from '@simple-module-py/ui/components/ui/button'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { ServerCrash, ServerOff } from 'lucide-react'; import { useEffect, useState } from 'react'; -import { API_BASE, VIEW_BASE, type WorkerSnapshot } from '../constants'; +import { api } from '../api'; +import { VIEW_BASE, type WorkerSnapshot } from '../constants'; import { diagnoseWorkerHealth, type WorkerHealthState } from '../worker-health'; interface Props { @@ -46,17 +48,14 @@ export function WorkerHealthBanner({ backlog }: Props) { setState(null); return; } - const controller = new AbortController(); - fetch(`${API_BASE}/workers`, { - headers: { Accept: 'application/json' }, - signal: controller.signal, - }) - .then((res) => (res.ok ? (res.json() as Promise) : null)) - .then((snapshot) => snapshot && setState(diagnose(snapshot))) - .catch(() => { - /* offline, aborted, or forbidden — say nothing rather than guess */ - }); - return () => controller.abort(); + let live = true; + apiResult(api.GET('/api/background_tasks/admin/workers')).then((result) => { + // Offline, forbidden or unmounted — say nothing rather than guess. + if (live && result.ok) setState(diagnose(result.data as WorkerSnapshot)); + }); + return () => { + live = false; + }; }, [backlog]); if (!state) return null; diff --git a/modules/background_tasks/background_tasks/pages/logs.ts b/modules/background_tasks/background_tasks/pages/logs.ts index 2e5f7ed4e..804e47431 100644 --- a/modules/background_tasks/background_tasks/pages/logs.ts +++ b/modules/background_tasks/background_tasks/pages/logs.ts @@ -1,6 +1,7 @@ // Frontend mirror of the retained-log DTOs (background_tasks/contracts/logs.py). -import { API_BASE } from './constants'; +import { apiResult } from '@simple-module-py/ui/lib/api'; +import { api } from './api'; export interface LogItem { id: string; @@ -42,9 +43,11 @@ export async function fetchExecutionLogs( level: string, page: number, ): Promise { - const query = new URLSearchParams({ page: String(page) }); - if (level) query.set('level', level); - const res = await fetch(`${API_BASE}/executions/${executionId}/logs?${query}`); - if (!res.ok) throw new Error(String(res.status)); - return (await res.json()) as LogPage; + const result = await apiResult( + api.GET('/api/background_tasks/admin/executions/{execution_id}/logs', { + params: { path: { execution_id: executionId }, query: { page, level: level || undefined } }, + }), + ); + if (!result.ok) throw new Error(String(result.status)); + return result.data as LogPage; } diff --git a/modules/background_tasks/background_tasks/pages/outbox.ts b/modules/background_tasks/background_tasks/pages/outbox.ts index acb406eb7..b7e138e3f 100644 --- a/modules/background_tasks/background_tasks/pages/outbox.ts +++ b/modules/background_tasks/background_tasks/pages/outbox.ts @@ -1,8 +1,9 @@ // Types and API calls for the durable-event outbox page (GH #459). import { keys, t } from '@simple-module-py/i18n'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { toast } from 'sonner'; -import { API_BASE } from './constants'; +import { api } from './api'; export const OUTBOX_STATUS = { PENDING: 'pending', @@ -49,51 +50,41 @@ export interface OutboxMessage { delivered_at: string | null; } -const OUTBOX_API = `${API_BASE}/outbox`; - -/** Raised for a non-2xx answer; carries the status so callers can pick a translated message. */ -class OutboxHttpError extends Error { - constructor(readonly status: number) { - super(`HTTP ${status}`); - } -} - -async function send(url: string, method: string): Promise { - const res = await fetch(url, { method, headers: { Accept: 'application/json' } }); - if (!res.ok) throw new OutboxHttpError(res.status); -} - /** The server's English `detail` is never shown: 409/404 get their own translated line. */ -function failureMessage(err: unknown, fallback: string): string { - if (err instanceof OutboxHttpError) { - if (err.status === 409) return t(keys.background_tasks.outbox.conflict); - if (err.status === 404) return t(keys.background_tasks.outbox.gone); - } +function failureMessage(status: number, fallback: string): string { + if (status === 409) return t(keys.background_tasks.outbox.conflict); + if (status === 404) return t(keys.background_tasks.outbox.gone); return fallback; } /** Send a dead message round again from attempt 1. Resolves true on success. */ export async function retryOutboxMessage(id: string): Promise { - try { - await send(`${OUTBOX_API}/${id}/retry`, 'POST'); - toast.success(t(keys.background_tasks.outbox.retried)); - return true; - } catch (err) { - toast.error(failureMessage(err, t(keys.background_tasks.outbox.retry_failed))); + const result = await apiResult( + api.POST('/api/background_tasks/admin/outbox/{message_id}/retry', { + params: { path: { message_id: id } }, + }), + ); + if (!result.ok) { + toast.error(failureMessage(result.status, t(keys.background_tasks.outbox.retry_failed))); return false; } + toast.success(t(keys.background_tasks.outbox.retried)); + return true; } /** Drop a pending or dead message without delivering it. */ export async function discardOutboxMessage(id: string): Promise { - try { - await send(`${OUTBOX_API}/${id}`, 'DELETE'); - toast.success(t(keys.background_tasks.outbox.discarded)); - return true; - } catch (err) { - toast.error(failureMessage(err, t(keys.background_tasks.outbox.discard_failed))); + const result = await apiResult( + api.DELETE('/api/background_tasks/admin/outbox/{message_id}', { + params: { path: { message_id: id } }, + }), + ); + if (!result.ok) { + toast.error(failureMessage(result.status, t(keys.background_tasks.outbox.discard_failed))); return false; } + toast.success(t(keys.background_tasks.outbox.discarded)); + return true; } /** `orders.contracts.events.OrderPlaced` → `OrderPlaced`, the part people recognise. */ diff --git a/modules/background_tasks/background_tasks/pages/retry.ts b/modules/background_tasks/background_tasks/pages/retry.ts index 31c54d255..779f0636a 100644 --- a/modules/background_tasks/background_tasks/pages/retry.ts +++ b/modules/background_tasks/background_tasks/pages/retry.ts @@ -3,8 +3,10 @@ // the list vs. navigate to the new row), so the fetch + toast live here. import { keys, t } from '@simple-module-py/i18n'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { toast } from 'sonner'; -import { API_BASE, QUEUE_ALL, STATUS_ALL, type TaskStatus, TENANT_ALL } from './constants'; +import { api } from './api'; +import { QUEUE_ALL, STATUS_ALL, type TaskStatus, TENANT_ALL } from './constants'; export interface Execution { id: string; @@ -46,22 +48,18 @@ export function retryExecution(execution: { } async function postRetry(execution: { id: string; task_name: string }): Promise { - try { - const res = await fetch(`${API_BASE}/executions/${execution.id}/retry`, { - method: 'POST', - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.detail || `HTTP ${res.status}`); - } - const created = (await res.json()) as Execution; - // Called, not captured at module scope, so it reads the live locale. - toast.success(t(keys.background_tasks.toasts.retried, { name: execution.task_name })); - return created; - } catch (err) { - toast.error(err instanceof Error ? err.message : t(keys.background_tasks.toasts.retry_failed)); + const result = await apiResult( + api.POST('/api/background_tasks/admin/executions/{execution_id}/retry', { + params: { path: { execution_id: execution.id } }, + }), + ); + if (!result.ok) { + toast.error(result.detail ?? t(keys.background_tasks.toasts.retry_failed)); return null; } + // Called, not captured at module scope, so it reads the live locale. + toast.success(t(keys.background_tasks.toasts.retried, { name: execution.task_name })); + return result.data as Execution; } /** @@ -83,32 +81,31 @@ export async function retryAllFailed(filters: { queue: string; tenant: string; }): Promise { - const params = new URLSearchParams(); - if (filters.status && filters.status !== STATUS_ALL) params.set('status', filters.status); - if (filters.taskName) params.set('q', filters.taskName); - if (filters.queue && filters.queue !== QUEUE_ALL) params.set('queue', filters.queue); - if (filters.tenant && filters.tenant !== TENANT_ALL) params.set('tenant_id', filters.tenant); - const query = params.toString(); - try { - const res = await fetch(`${API_BASE}/executions/retry-failed${query ? `?${query}` : ''}`, { - method: 'POST', - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.detail || `HTTP ${res.status}`); - } - const { queued, remaining } = (await res.json()) as { queued: number; remaining: number }; - toast.success(t(keys.background_tasks.toasts.bulk_retried, { count: queued }), { - description: - remaining > 0 - ? t(keys.background_tasks.toasts.bulk_remaining, { count: remaining }) - : undefined, - }); - return queued; - } catch (err) { - toast.error( - err instanceof Error ? err.message : t(keys.background_tasks.toasts.bulk_retry_failed), - ); + const result = await apiResult( + api.POST('/api/background_tasks/admin/executions/retry-failed', { + params: { + query: { + status: + filters.status && filters.status !== STATUS_ALL + ? (filters.status as TaskStatus) + : undefined, + q: filters.taskName || undefined, + queue: filters.queue && filters.queue !== QUEUE_ALL ? filters.queue : undefined, + tenant_id: filters.tenant && filters.tenant !== TENANT_ALL ? filters.tenant : undefined, + }, + }, + }), + ); + if (!result.ok) { + toast.error(result.detail ?? t(keys.background_tasks.toasts.bulk_retry_failed)); return null; } + const { queued, remaining } = result.data; + toast.success(t(keys.background_tasks.toasts.bulk_retried, { count: queued }), { + description: + remaining > 0 + ? t(keys.background_tasks.toasts.bulk_remaining, { count: remaining }) + : undefined, + }); + return queued; } diff --git a/modules/background_tasks/tests-js/background_tasks_DetailPage.test.tsx b/modules/background_tasks/tests-js/background_tasks_DetailPage.test.tsx index e39a93a61..efb6f5e84 100644 --- a/modules/background_tasks/tests-js/background_tasks_DetailPage.test.tsx +++ b/modules/background_tasks/tests-js/background_tasks_DetailPage.test.tsx @@ -22,7 +22,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); import { toast } from 'sonner'; import type { TaskDetail } from '../background_tasks/pages/constants'; import Detail from '../background_tasks/pages/Detail'; -import { stubFetch } from './stub-fetch'; +import { requestsOf, stubFetch } from './stub-fetch'; const rd = keys.background_tasks.retry_dialog; @@ -136,10 +136,10 @@ describe('background_tasks Detail page', () => { fireEvent.click(await screen.findByRole('button', { name: rd.confirm })); await waitFor(() => expect(visit).toHaveBeenCalledWith('/admin/background-tasks/e2')); - expect(fetchMock).toHaveBeenCalledWith( - '/api/background_tasks/admin/executions/e1/retry', - expect.objectContaining({ method: 'POST' }), - ); + expect(requestsOf(fetchMock)).toContainEqual({ + url: '/api/background_tasks/admin/executions/e1/retry', + method: 'POST', + }); expect(toast.success).toHaveBeenCalledTimes(1); }); diff --git a/modules/background_tasks/tests-js/background_tasks_IndexPage.test.tsx b/modules/background_tasks/tests-js/background_tasks_IndexPage.test.tsx index 001d921a1..da4125265 100644 --- a/modules/background_tasks/tests-js/background_tasks_IndexPage.test.tsx +++ b/modules/background_tasks/tests-js/background_tasks_IndexPage.test.tsx @@ -22,7 +22,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); import { toast } from 'sonner'; import Index from '../background_tasks/pages/Index'; import type { Execution } from '../background_tasks/pages/retry'; -import { stubFetch } from './stub-fetch'; +import { requestsOf, stubFetch } from './stub-fetch'; const rd = keys.background_tasks.retry_dialog; const ra = keys.background_tasks.retry_all_dialog; @@ -97,10 +97,10 @@ describe('background_tasks Index page', () => { fireEvent.click(screen.getByRole('button', { name: rd.confirm })); await waitFor(() => expect(reload).toHaveBeenCalledWith(RELOAD)); - expect(fetchMock).toHaveBeenCalledWith( - '/api/background_tasks/admin/executions/e1/retry', - expect.objectContaining({ method: 'POST' }), - ); + expect(requestsOf(fetchMock)).toContainEqual({ + url: '/api/background_tasks/admin/executions/e1/retry', + method: 'POST', + }); expect(toast.success).toHaveBeenCalledTimes(1); }); @@ -135,10 +135,10 @@ describe('background_tasks Index page', () => { fireEvent.click(screen.getByRole('button', { name: ra.confirm })); await waitFor(() => expect(reload).toHaveBeenCalledWith(RELOAD)); - expect(fetchMock).toHaveBeenCalledWith( - '/api/background_tasks/admin/executions/retry-failed?status=failed&q=demo&queue=default', - expect.objectContaining({ method: 'POST' }), - ); + expect(requestsOf(fetchMock)).toContainEqual({ + url: '/api/background_tasks/admin/executions/retry-failed?status=failed&q=demo&queue=default', + method: 'POST', + }); expect(toast.success).toHaveBeenCalledTimes(1); }); diff --git a/modules/background_tasks/tests-js/background_tasks_LogsTab.test.tsx b/modules/background_tasks/tests-js/background_tasks_LogsTab.test.tsx index 0aad38f3d..7ab5c4a57 100644 --- a/modules/background_tasks/tests-js/background_tasks_LogsTab.test.tsx +++ b/modules/background_tasks/tests-js/background_tasks_LogsTab.test.tsx @@ -8,7 +8,7 @@ configureI18n({ locale: 'en', messages: {} }); vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); import { LogsPanel } from '../background_tasks/pages/components/LogsPanel'; -import { stubFetch } from './stub-fetch'; +import { requestsOf, stubFetch } from './stub-fetch'; const line = (message: string, level_name = 'INFO', level = 20) => ({ id: message, @@ -34,7 +34,7 @@ describe('LogsPanel', () => { per_page: 50, }); render(); - const urls = () => (fetchMock.mock.calls as unknown as string[][]).map((c) => c[0]); + const urls = () => requestsOf(fetchMock).map((c) => c.url); expect(await screen.findByText('copied 3 rows')).toBeInTheDocument(); expect(screen.getByText('disk full')).toBeInTheDocument(); diff --git a/modules/background_tasks/tests-js/background_tasks_OutboxPage.test.tsx b/modules/background_tasks/tests-js/background_tasks_OutboxPage.test.tsx index 48c21cfa7..a289d7fd1 100644 --- a/modules/background_tasks/tests-js/background_tasks_OutboxPage.test.tsx +++ b/modules/background_tasks/tests-js/background_tasks_OutboxPage.test.tsx @@ -23,7 +23,7 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); import { toast } from 'sonner'; import Outbox from '../background_tasks/pages/Outbox'; import type { OutboxMessage } from '../background_tasks/pages/outbox'; -import { stubFetch } from './stub-fetch'; +import { requestsOf, stubFetch } from './stub-fetch'; const ob = keys.background_tasks.outbox; @@ -99,10 +99,10 @@ describe('background_tasks Outbox page', () => { fireEvent.click(screen.getByRole('button', { name: ob.retry })); await waitFor(() => expect(reload).toHaveBeenCalled()); - expect(fetchMock).toHaveBeenCalledWith( - '/api/background_tasks/admin/outbox/m1/retry', - expect.objectContaining({ method: 'POST' }), - ); + expect(requestsOf(fetchMock)).toContainEqual({ + url: '/api/background_tasks/admin/outbox/m1/retry', + method: 'POST', + }); expect(toast.success).toHaveBeenCalledWith(ob.retried); expect(reload).toHaveBeenCalledWith({ only: ['messages', 'counts', 'pagination'] }); }); @@ -150,10 +150,10 @@ describe('background_tasks Outbox page', () => { fireEvent.click(screen.getByRole('button', { name: ob.discard })); await waitFor(() => expect(toast.success).toHaveBeenCalledWith(ob.discarded)); - expect(fetchMock).toHaveBeenCalledWith( - '/api/background_tasks/admin/outbox/m1', - expect.objectContaining({ method: 'DELETE' }), - ); + expect(requestsOf(fetchMock)).toContainEqual({ + url: '/api/background_tasks/admin/outbox/m1', + method: 'DELETE', + }); expect(reload).toHaveBeenCalled(); }); diff --git a/modules/background_tasks/tests-js/background_tasks_WorkerHealthBanner.test.tsx b/modules/background_tasks/tests-js/background_tasks_WorkerHealthBanner.test.tsx new file mode 100644 index 000000000..8ef957a73 --- /dev/null +++ b/modules/background_tasks/tests-js/background_tasks_WorkerHealthBanner.test.tsx @@ -0,0 +1,49 @@ +import '@testing-library/jest-dom/vitest'; +import { configureI18n, keys } from '@simple-module-py/i18n'; +import { render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +configureI18n({ locale: 'en', messages: {} }); + +vi.mock('@inertiajs/react', () => ({ + Link: ({ children }: { children: unknown }) => {children as string}, +})); + +import { WorkerHealthBanner } from '../background_tasks/pages/components/WorkerHealthBanner'; +import { requestsOf, stubFetch } from './stub-fetch'; + +const snapshot = (reachable: boolean) => ({ + broker_reachable: reachable, + polled_at: '2026-01-01T00:00:00Z', + workers: [], + error: null, +}); + +describe('WorkerHealthBanner', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('does not call the API without a backlog', () => { + const fetchMock = stubFetch(200, snapshot(true)); + render(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('warns when the broker is unreachable', async () => { + const fetchMock = stubFetch(200, snapshot(false)); + render(); + expect( + await screen.findByText(keys.background_tasks.worker_health.broker_unreachable_title), + ).toBeInTheDocument(); + expect(requestsOf(fetchMock)[0]).toEqual({ + url: '/api/background_tasks/admin/workers', + method: 'GET', + }); + }); + + it('stays silent when the API call fails', async () => { + const fetchMock = stubFetch(403, { detail: 'nope' }); + const { container } = render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/modules/background_tasks/tests-js/outbox.test.ts b/modules/background_tasks/tests-js/outbox.test.ts index f27369e9d..1dc0c915f 100644 --- a/modules/background_tasks/tests-js/outbox.test.ts +++ b/modules/background_tasks/tests-js/outbox.test.ts @@ -8,7 +8,7 @@ import { retryOutboxMessage, shortName, } from '../background_tasks/pages/outbox'; -import { stubFetch } from './stub-fetch'; +import { requestsOf, stubFetch } from './stub-fetch'; describe('outbox actions', () => { afterEach(() => { @@ -19,19 +19,19 @@ describe('outbox actions', () => { it('retries with a POST to the row', async () => { const fetchMock = stubFetch(200, { id: 'm1', status: 'pending' }); expect(await retryOutboxMessage('m1')).toBe(true); - expect(fetchMock).toHaveBeenCalledWith( - '/api/background_tasks/admin/outbox/m1/retry', - expect.objectContaining({ method: 'POST' }), - ); + expect(requestsOf(fetchMock)).toContainEqual({ + url: '/api/background_tasks/admin/outbox/m1/retry', + method: 'POST', + }); }); it('discards with a DELETE and accepts 204', async () => { const fetchMock = stubFetch(204); expect(await discardOutboxMessage('m1')).toBe(true); - expect(fetchMock).toHaveBeenCalledWith( - '/api/background_tasks/admin/outbox/m1', - expect.objectContaining({ method: 'DELETE' }), - ); + expect(requestsOf(fetchMock)).toContainEqual({ + url: '/api/background_tasks/admin/outbox/m1', + method: 'DELETE', + }); }); it('shows a translated conflict message, never the server English', async () => { diff --git a/modules/background_tasks/tests-js/stub-fetch.ts b/modules/background_tasks/tests-js/stub-fetch.ts index c60911756..6bc7381f4 100644 --- a/modules/background_tasks/tests-js/stub-fetch.ts +++ b/modules/background_tasks/tests-js/stub-fetch.ts @@ -8,3 +8,11 @@ export function stubFetch(status: number, body: unknown = {}) { vi.stubGlobal('fetch', fetchMock); return fetchMock; } + +/** The `(path+query, method)` of every call: the typed client passes `Request` objects. */ +export function requestsOf(fetchMock: ReturnType) { + return (fetchMock.mock.calls as unknown as [Request][]).map(([req]) => { + const url = new URL(req.url); + return { url: `${url.pathname}${url.search}`, method: req.method }; + }); +} From c4bd61a2903b1d1357c78c9931b6fd9e2cd3295b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 13:43:30 +0200 Subject: [PATCH 02/16] branding Manage: typed API client for publish, apiFetch for image routes (#504) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/branding/branding/pages/Manage.tsx | 59 +- .../branding/branding/pages/api.generated.ts | 618 ++++++++++++++++++ modules/branding/branding/pages/api.ts | 9 + .../tests-js/branding_ManageSave.test.tsx | 69 ++ 4 files changed, 722 insertions(+), 33 deletions(-) create mode 100644 modules/branding/branding/pages/api.generated.ts create mode 100644 modules/branding/branding/pages/api.ts create mode 100644 modules/branding/tests-js/branding_ManageSave.test.tsx diff --git a/modules/branding/branding/pages/Manage.tsx b/modules/branding/branding/pages/Manage.tsx index 13a10805c..e30be7ec8 100644 --- a/modules/branding/branding/pages/Manage.tsx +++ b/modules/branding/branding/pages/Manage.tsx @@ -6,6 +6,7 @@ import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; import { Input } from '@simple-module-py/ui/components/ui/input'; import { Label } from '@simple-module-py/ui/components/ui/label'; import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; +import { type ApiResult, apiFetch, apiResult } from '@simple-module-py/ui/lib/api'; import type { SharedProps } from '@simple-module-py/ui/types'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -18,6 +19,7 @@ import { isValidColor, normalizeHex } from '../components/hex'; import { ImageDropzones, type ImageKind } from '../components/ImageDropzones'; import { PresetField, type PresetOption } from '../components/PresetField'; import { PreviewTabs } from '../components/PreviewTabs'; +import { api } from './api'; /** Matches `BUILTIN_PRESETS[0]` in `branding/presets.py`. */ const DEFAULT_SWATCH = '#0f766e'; @@ -26,15 +28,6 @@ const DEFAULT_APP_NAME = 'SimpleModule'; /** Mirrors `MAX_APP_NAME_LEN` in `branding/constants.py`. */ const MAX_APP_NAME = 60; -async function readError(res: Response): Promise { - try { - const body = await res.json(); - return typeof body?.detail === 'string' ? body.detail : res.statusText; - } catch { - return res.statusText; - } -} - // ``designPacks`` and ``presets`` are page props, not shared ones: the // choices depend on which modules the host has installed, so only the view // can supply them. @@ -73,51 +66,51 @@ function Manage(page: ManageProps) { const set = (patch: Partial) => setForm((current) => ({ ...current, ...patch })); - async function run(work: () => Promise, errorMsg: string) { + async function run(work: () => Promise>, errorMsg: string) { setBusy(true); - try { - const res = await work(); - if (!res.ok) throw new Error(await readError(res)); - toast.success(t(keys.branding.manage.saved_toast)); - router.reload(); - } catch (err) { - toast.error(`${errorMsg}: ${(err as Error).message}`); - } finally { - setBusy(false); + const result = await work(); + setBusy(false); + if (!result.ok) { + toast.error(result.detail ? `${errorMsg}: ${result.detail}` : errorMsg); + return; } + toast.success(t(keys.branding.manage.saved_toast)); + router.reload(); } const publish = () => run( () => - fetch('/api/branding/', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - app_name: form.appName, - primary_color: normalizeHex(form.color) ?? '', - design_pack: form.designPack, - banner_message: form.bannerMessage, - banner_severity: form.bannerSeverity, - footer_text: form.footerText, - footer_links: form.footerLinks, + apiResult( + api.PUT('/api/branding/', { + body: { + app_name: form.appName, + primary_color: normalizeHex(form.color) ?? '', + design_pack: form.designPack, + banner_message: form.bannerMessage, + banner_severity: form.bannerSeverity, + footer_text: form.footerText, + footer_links: form.footerLinks, + }, }), - }), + ), t(keys.branding.manage.error_toast), ); + // The image routes take the kind as a path segment and the upload is + // multipart, so they go through the untyped twin. const uploadImage = (kind: ImageKind, file: File) => { const body = new FormData(); body.append('file', file); return run( - () => fetch(`/api/branding/${kind}`, { method: 'POST', body }), + () => apiFetch(`/api/branding/${kind}`, { method: 'POST', body }), t(keys.branding.manage.upload_error_toast), ); }; const removeImage = (kind: ImageKind) => run( - () => fetch(`/api/branding/${kind}`, { method: 'DELETE' }), + () => apiFetch(`/api/branding/${kind}`, { method: 'DELETE' }), t(keys.branding.manage.error_toast), ); diff --git a/modules/branding/branding/pages/api.generated.ts b/modules/branding/branding/pages/api.generated.ts new file mode 100644 index 000000000..23eb6adea --- /dev/null +++ b/modules/branding/branding/pages/api.generated.ts @@ -0,0 +1,618 @@ +// AUTO-GENERATED by `smpy openapi gen-types` from the Branding module's /api +// operations — do not edit. Regenerate with `smpy openapi gen-types --module branding` +// (`make gen-api-types` in the framework repo). +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/branding/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Branding */ + get: operations["branding_get_branding"]; + /** Update Branding */ + put: operations["branding_update_branding"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/branding/logo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Serve Logo */ + get: operations["branding_serve_logo"]; + put?: never; + /** Upload Logo */ + post: operations["branding_upload_logo"]; + /** Clear Logo */ + delete: operations["branding_clear_logo"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/branding/presets/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Apply Preset + * @description Apply a named look, leaving app name, images and banner untouched. + */ + post: operations["branding_apply_preset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/branding/logo-dark": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Serve Logo Dark */ + get: operations["branding_serve_logo_dark"]; + put?: never; + /** Upload Logo Dark */ + post: operations["branding_upload_logo_dark"]; + /** Clear Logo Dark */ + delete: operations["branding_clear_logo_dark"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/branding/favicon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Serve Favicon */ + get: operations["branding_serve_favicon"]; + put?: never; + /** Upload Favicon */ + post: operations["branding_upload_favicon"]; + /** Clear Favicon */ + delete: operations["branding_clear_favicon"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/branding/tenant/{asset}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upload Tenant Asset */ + post: operations["branding_upload_tenant_asset"]; + /** Clear Tenant Asset */ + delete: operations["branding_clear_tenant_asset"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** Body_branding_upload_favicon */ + Body_branding_upload_favicon: { + /** File */ + file: string; + }; + /** Body_branding_upload_logo */ + Body_branding_upload_logo: { + /** File */ + file: string; + }; + /** Body_branding_upload_logo_dark */ + Body_branding_upload_logo_dark: { + /** File */ + file: string; + }; + /** Body_branding_upload_tenant_asset */ + Body_branding_upload_tenant_asset: { + /** File */ + file: string; + }; + /** + * BrandingOut + * @description Current branding, with logo/favicon resolved to download URLs. + */ + BrandingOut: { + /** App Name */ + app_name: string; + /** + * Primary Color + * @default + */ + primary_color: string; + /** + * Design Pack + * @default + */ + design_pack: string; + /** Logo Url */ + logo_url?: string | null; + /** Logo Dark Url */ + logo_dark_url?: string | null; + /** Favicon Url */ + favicon_url?: string | null; + /** + * Banner Message + * @default + */ + banner_message: string; + /** + * Banner Severity + * @default + */ + banner_severity: string; + /** + * Footer Text + * @default + */ + footer_text: string; + /** Footer Links */ + footer_links?: components["schemas"]["FooterLink"][]; + }; + /** + * BrandingUpdate + * @description Editable text fields. Logo/favicon are set via dedicated upload routes. + */ + BrandingUpdate: { + /** App Name */ + app_name?: string | null; + /** Primary Color */ + primary_color?: string | null; + /** Design Pack */ + design_pack?: string | null; + /** Banner Message */ + banner_message?: string | null; + /** Banner Severity */ + banner_severity?: string | null; + /** Footer Text */ + footer_text?: string | null; + /** Footer Links */ + footer_links?: components["schemas"]["FooterLink"][] | null; + }; + /** + * FooterLink + * @description One link in the site footer. + * + * Deliberately just a label and a target: the surface removed in #273 grew + * columns, social icons and a tagline, and the thing hosts actually lost was + * the ability to stop advertising the framework's repository. + */ + FooterLink: { + /** Label */ + label: string; + /** Href */ + href: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + branding_get_branding: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + }; + }; + branding_update_branding: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BrandingUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + branding_serve_logo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + branding_upload_logo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_branding_upload_logo"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + branding_clear_logo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + }; + }; + branding_apply_preset: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + branding_serve_logo_dark: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + branding_upload_logo_dark: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_branding_upload_logo_dark"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + branding_clear_logo_dark: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + }; + }; + branding_serve_favicon: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + branding_upload_favicon: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_branding_upload_favicon"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + branding_clear_favicon: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + }; + }; + branding_upload_tenant_asset: { + parameters: { + query?: never; + header?: never; + path: { + asset: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_branding_upload_tenant_asset"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + branding_clear_tenant_asset: { + parameters: { + query?: never; + header?: never; + path: { + asset: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BrandingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/modules/branding/branding/pages/api.ts b/modules/branding/branding/pages/api.ts new file mode 100644 index 000000000..06a4c1eae --- /dev/null +++ b/modules/branding/branding/pages/api.ts @@ -0,0 +1,9 @@ +/** + * Typed client for this module's `/api` routes (#504). The `paths` types are + * generated from the routes — regenerate with `make gen-api-types` (or + * `smpy openapi gen-types --module branding`), never edit them by hand. + */ +import { createApi } from '@simple-module-py/ui/lib/api'; +import type { paths } from './api.generated'; + +export const api = createApi(); diff --git a/modules/branding/tests-js/branding_ManageSave.test.tsx b/modules/branding/tests-js/branding_ManageSave.test.tsx new file mode 100644 index 000000000..2658974db --- /dev/null +++ b/modules/branding/tests-js/branding_ManageSave.test.tsx @@ -0,0 +1,69 @@ +import '@testing-library/jest-dom/vitest'; +import { configureI18n, keys } from '@simple-module-py/i18n'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +configureI18n({ locale: 'en', messages: {} }); + +const reload = vi.hoisted(() => vi.fn()); +vi.mock('@inertiajs/react', () => ({ + Head: () => null, + Link: ({ children }: { children: React.ReactNode }) => {children}, + router: { reload }, + usePage: () => ({ props: {} }), +})); +vi.mock('@simple-module-py/ui/layouts/AdminLayout', () => ({ AdminLayout: () => null })); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { toast } from 'sonner'; +import Manage from '../branding/pages/Manage'; + +function props() { + return { + auth: { permissions: ['branding.manage'] }, + branding: { appName: 'Acme' }, + } as unknown as ComponentProps; +} + +function stub(status: number, body: unknown) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +describe('branding Manage publish', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('PUTs the form as JSON, toasts and reloads', async () => { + const fetchMock = stub(200, {}); + render(); + fireEvent.change(screen.getByLabelText(keys.branding.manage.app_name_label), { + target: { value: 'Globex' }, + }); + fireEvent.click(screen.getByRole('button', { name: keys.branding.manage.publish })); + + await waitFor(() => expect(reload).toHaveBeenCalled()); + const [req] = fetchMock.mock.calls[0] as unknown as [Request]; + expect(req.method).toBe('PUT'); + expect(new URL(req.url).pathname).toBe('/api/branding/'); + expect((await req.json()).app_name).toBe('Globex'); + expect(toast.success).toHaveBeenCalledWith(keys.branding.manage.saved_toast); + }); + + it('shows the server detail after the translated prefix on failure', async () => { + stub(400, { detail: 'bad colour' }); + render(); + fireEvent.change(screen.getByLabelText(keys.branding.manage.app_name_label), { + target: { value: 'Globex' }, + }); + fireEvent.click(screen.getByRole('button', { name: keys.branding.manage.publish })); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(toast.error).toHaveBeenCalledWith(`${keys.branding.manage.error_toast}: bad colour`); + expect(reload).not.toHaveBeenCalled(); + }); +}); From 53205e11717b73a68d373ef28ed5ca287fd14f0c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 13:44:25 +0200 Subject: [PATCH 03/16] settings pages: typed API client for module save/reset, apiFetch for test-connection (#504) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/settings/settings/locales/en.json | 1 + .../settings/settings/pages/api.generated.ts | 1038 +++++++++++++++++ modules/settings/settings/pages/api.ts | 9 + .../settings/pages/components/ModuleForm.tsx | 27 +- .../pages/components/TestConnectionButton.tsx | 19 +- modules/settings/tests-js/JsonFields.test.tsx | 8 +- modules/settings/tests-js/ModuleForm.test.tsx | 39 +- .../settings_TestConnectionButton.test.tsx | 47 + packages/i18n/src/generated-resources.ts | 1 + packages/i18n/src/keys.generated.ts | 1 + 10 files changed, 1148 insertions(+), 42 deletions(-) create mode 100644 modules/settings/settings/pages/api.generated.ts create mode 100644 modules/settings/settings/pages/api.ts create mode 100644 modules/settings/tests-js/settings_TestConnectionButton.test.tsx diff --git a/modules/settings/settings/locales/en.json b/modules/settings/settings/locales/en.json index 8b0fc1920..63355aeb7 100644 --- a/modules/settings/settings/locales/en.json +++ b/modules/settings/settings/locales/en.json @@ -114,6 +114,7 @@ "test_connection": "Test {name} connection", "test_connection_generic": "Test connection", "testing": "Testing…", + "test_failed": "The connection test could not be run.", "last_test_success": "Last test succeeded {ago}", "last_test_failure": "Last test failed {ago}", "env_var_hint": "Environment variable name for this field, used by \"smpy settings import-from-env\"", diff --git a/modules/settings/settings/pages/api.generated.ts b/modules/settings/settings/pages/api.generated.ts new file mode 100644 index 000000000..31590e4f5 --- /dev/null +++ b/modules/settings/settings/pages/api.generated.ts @@ -0,0 +1,1038 @@ +// AUTO-GENERATED by `smpy openapi gen-types` from the Settings module's /api +// operations — do not edit. Regenerate with `smpy openapi gen-types --module settings` +// (`make gen-api-types` in the framework repo). +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/settings/modules": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Modules */ + get: operations["settings_list_modules"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/modules/{package}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update Module */ + put: operations["settings_update_module"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/modules/{package}/{field}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Clear Module Field */ + delete: operations["settings_clear_module_field"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/tenant/current": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Current */ + get: operations["settings_list_current"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/tenant/current/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Current */ + get: operations["settings_get_current"]; + /** Put Current */ + put: operations["settings_put_current"]; + post?: never; + /** Delete Current */ + delete: operations["settings_delete_current"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Settings */ + get: operations["settings_list_settings"]; + put?: never; + /** Create Setting */ + post: operations["settings_create_setting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/resolve/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Resolve Setting */ + get: operations["settings_resolve_setting"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/system/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get System Setting */ + get: operations["settings_get_system_setting"]; + /** Upsert System Setting */ + put: operations["settings_upsert_system_setting"]; + post?: never; + /** Delete System Setting */ + delete: operations["settings_delete_system_setting"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/tenant/{scope_id}/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tenant Setting */ + get: operations["settings_get_tenant_setting"]; + /** Upsert Tenant Setting */ + put: operations["settings_upsert_tenant_setting"]; + post?: never; + /** Delete Tenant Setting */ + delete: operations["settings_delete_tenant_setting"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/user/{scope_id}/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Setting */ + get: operations["settings_get_user_setting"]; + /** Upsert User Setting */ + put: operations["settings_upsert_user_setting"]; + post?: never; + /** Delete User Setting */ + delete: operations["settings_delete_user_setting"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/{setting_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Setting */ + get: operations["settings_get_setting"]; + /** Update Setting */ + put: operations["settings_update_setting"]; + post?: never; + /** Delete Setting */ + delete: operations["settings_delete_setting"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * SettingCreate + * @description Payload to create a new setting at an explicit scope. + */ + SettingCreate: { + /** @default system */ + scope: components["schemas"]["SettingScope"]; + /** + * Scope Id + * @default + */ + scope_id: string; + /** Key */ + key: string; + /** Value */ + value: string; + /** @default string */ + value_type: components["schemas"]["SettingValueType"]; + /** Description */ + description?: string | null; + }; + /** + * SettingOut + * @description A setting returned by the API. + */ + SettingOut: { + /** Id */ + id: number; + scope: components["schemas"]["SettingScope"]; + /** Scope Id */ + scope_id: string; + /** Key */ + key: string; + /** Value */ + value: string; + /** @default string */ + value_type: components["schemas"]["SettingValueType"]; + /** Description */ + description?: string | null; + /** Created At */ + created_at?: string | null; + /** Updated At */ + updated_at?: string | null; + }; + /** + * SettingScope + * @description Override level for a setting entry. + * @enum {string} + */ + SettingScope: "system" | "tenant" | "user"; + /** + * SettingUpdate + * @description Payload to update an existing setting by id. Scope cannot change. + */ + SettingUpdate: { + /** Value */ + value?: string | null; + value_type?: components["schemas"]["SettingValueType"] | null; + /** Description */ + description?: string | null; + }; + /** + * SettingUpsert + * @description Payload for upsert operations (value + optional type + description). + * + * ``value_type`` is optional: when absent on an update it preserves the + * existing row's type, and on a create it defaults to ``STRING``. + */ + SettingUpsert: { + /** Value */ + value: string; + value_type?: components["schemas"]["SettingValueType"] | null; + /** Description */ + description?: string | null; + }; + /** + * SettingValueType + * @description How the stored ``value`` string should be interpreted. + * @enum {string} + */ + SettingValueType: "string" | "bool" | "int" | "float" | "json"; + /** + * TenantSettingView + * @description One overridable key, as the active tenant sees it. + */ + TenantSettingView: { + /** Key */ + key: string; + /** Description */ + description: string; + value_type: components["schemas"]["SettingValueType"]; + /** Inherited */ + inherited: string; + /** Value */ + value: string | null; + /** Effective */ + effective: string; + /** + * Upload Url + * @default + */ + upload_url: string; + /** + * Description Key + * @default + */ + description_key: string; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + settings_list_modules: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + }; + }; + settings_update_module: { + parameters: { + query?: never; + header?: never; + path: { + package: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_clear_module_field: { + parameters: { + query?: never; + header?: never; + path: { + package: string; + field: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_list_current: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TenantSettingView"][]; + }; + }; + }; + }; + settings_get_current: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_put_current: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SettingUpsert"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_delete_current: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_list_settings: { + parameters: { + query?: { + scope?: components["schemas"]["SettingScope"] | null; + scope_id?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_create_setting: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SettingCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_resolve_setting: { + parameters: { + query?: { + user_id?: string | null; + tenant_id?: string | null; + }; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_get_system_setting: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_upsert_system_setting: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SettingUpsert"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_delete_system_setting: { + parameters: { + query?: never; + header?: never; + path: { + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_get_tenant_setting: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_upsert_tenant_setting: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SettingUpsert"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_delete_tenant_setting: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_get_user_setting: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_upsert_user_setting: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SettingUpsert"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_delete_user_setting: { + parameters: { + query?: never; + header?: never; + path: { + scope_id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_get_setting: { + parameters: { + query?: never; + header?: never; + path: { + setting_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_update_setting: { + parameters: { + query?: never; + header?: never; + path: { + setting_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SettingUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SettingOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + settings_delete_setting: { + parameters: { + query?: never; + header?: never; + path: { + setting_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/modules/settings/settings/pages/api.ts b/modules/settings/settings/pages/api.ts new file mode 100644 index 000000000..5a288c112 --- /dev/null +++ b/modules/settings/settings/pages/api.ts @@ -0,0 +1,9 @@ +/** + * Typed client for this module's `/api` routes (#504). The `paths` types are + * generated from the routes — regenerate with `make gen-api-types` (or + * `smpy openapi gen-types --module settings`), never edit them by hand. + */ +import { createApi } from '@simple-module-py/ui/lib/api'; +import type { paths } from './api.generated'; + +export const api = createApi(); diff --git a/modules/settings/settings/pages/components/ModuleForm.tsx b/modules/settings/settings/pages/components/ModuleForm.tsx index 13cb06527..09bad0035 100644 --- a/modules/settings/settings/pages/components/ModuleForm.tsx +++ b/modules/settings/settings/pages/components/ModuleForm.tsx @@ -2,7 +2,9 @@ import { router } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Card } from '@simple-module-py/ui/components/ui/card'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { useEffect, useMemo, useState } from 'react'; +import { api } from '../api'; import type { FieldMeta } from './FieldInput'; import { ModuleFieldRow } from './ModuleFieldRow'; import { TestConnectionButton } from './TestConnectionButton'; @@ -87,20 +89,20 @@ export function ModuleForm({ module: m, checks = [] }: Props) { setFormErrors([]); const changed: Record = {}; for (const name of modifiedFields) changed[name] = values[name]; - const resp = await fetch(`/api/settings/modules/${m.package}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(changed), - }); - if (resp.status === 422) { - const body = await resp.json(); + const result = await apiResult( + api.PUT('/api/settings/modules/{package}', { + params: { path: { package: m.package } }, + body: changed, + }), + ); + if (result.status === 422) { const split = splitValidationErrors( - body.detail, + (result.error as { detail?: unknown } | null)?.detail, m.fields.map((f) => f.name), ); setErrors(split.fields); setFormErrors(split.form); - } else if (!resp.ok) { + } else if (!result.ok) { setFormErrors([t(keys.settings.modules_form.save_failed)]); } else { // Keeping the form mounted lets the confirmation remain visible instead @@ -113,7 +115,12 @@ export function ModuleForm({ module: m, checks = [] }: Props) { async function onReset(name: string) { setSaved(false); - await fetch(`/api/settings/modules/${m.package}/${name}`, { method: 'DELETE' }); + // A failed clear still reloads: the reload shows what the server holds. + await apiResult( + api.DELETE('/api/settings/modules/{package}/{field}', { + params: { path: { package: m.package, field: name } }, + }), + ); router.reload({ only: ['modules'] }); } diff --git a/modules/settings/settings/pages/components/TestConnectionButton.tsx b/modules/settings/settings/pages/components/TestConnectionButton.tsx index a96efaea1..f5124d27f 100644 --- a/modules/settings/settings/pages/components/TestConnectionButton.tsx +++ b/modules/settings/settings/pages/components/TestConnectionButton.tsx @@ -1,6 +1,7 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { useRelativeTime } from '@simple-module-py/ui/hooks/use-relative-time'; +import { apiFetch } from '@simple-module-py/ui/lib/api'; import { CheckCircle2, PlugZap, XCircle } from 'lucide-react'; import { useEffect, useState } from 'react'; import { ROUTES } from '../routes'; @@ -52,11 +53,12 @@ export function TestConnectionButton({ pkg, checks }: Props) { setBusy(true); setError(null); setResults(null); - try { - const resp = await fetch(ROUTES.testConnection(pkg), { method: 'POST' }); - if (!resp.ok) throw new Error(resp.statusText); - const body = await resp.json(); - const checkResults: CheckResult[] = body.checks ?? []; + // A view route (`/admin/settings/test-connection/…`), not in the OpenAPI document. + const result = await apiFetch<{ checks?: CheckResult[] }>(ROUTES.testConnection(pkg), { + method: 'POST', + }); + if (result.ok) { + const checkResults = result.data?.checks ?? []; setResults(checkResults); setLast( writeLastTest( @@ -64,12 +66,11 @@ export function TestConnectionButton({ pkg, checks }: Props) { checkResults.every((c) => c.status === 'healthy'), ), ); - } catch (err) { - setError((err as Error).message); + } else { + setError(result.detail ?? t(keys.settings.modules.test_failed)); setLast(writeLastTest(pkg, false)); - } finally { - setBusy(false); } + setBusy(false); } return ( diff --git a/modules/settings/tests-js/JsonFields.test.tsx b/modules/settings/tests-js/JsonFields.test.tsx index 6b76e3aaa..86ea4e6a2 100644 --- a/modules/settings/tests-js/JsonFields.test.tsx +++ b/modules/settings/tests-js/JsonFields.test.tsx @@ -53,7 +53,7 @@ function renderForm(fields: FieldMeta[]) { async function savedBody(fetchMock: ReturnType) { fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(fetchMock).toHaveBeenCalled()); - return JSON.parse(fetchMock.mock.calls[0][1].body); + return (fetchMock.mock.calls[0][0] as Request).json(); } afterEach(() => { @@ -62,7 +62,7 @@ afterEach(() => { describe('JSON-typed fields (GH #487)', () => { test('a dict field is sent as the parsed object, not its text', async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + const fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); renderForm([field({ name: 'task_routes_override', value: {}, input_type: 'json' })]); @@ -82,7 +82,7 @@ describe('JSON-typed fields (GH #487)', () => { }); test('an unset int | None is an empty number box, not the text "null"', async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + const fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); renderForm([field({ name: 'worker_concurrency', input_type: 'int', nullable: true })]); @@ -95,7 +95,7 @@ describe('JSON-typed fields (GH #487)', () => { }); test('clearing an int | None sends null', async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + const fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); renderForm([ field({ name: 'worker_concurrency', value: 4, input_type: 'int', nullable: true }), diff --git a/modules/settings/tests-js/ModuleForm.test.tsx b/modules/settings/tests-js/ModuleForm.test.tsx index 7ecaadce9..4639f999a 100644 --- a/modules/settings/tests-js/ModuleForm.test.tsx +++ b/modules/settings/tests-js/ModuleForm.test.tsx @@ -69,7 +69,7 @@ afterEach(() => { describe('ModuleForm', () => { test('shows success feedback after settings are saved', async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + const fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); render(); @@ -77,10 +77,10 @@ describe('ModuleForm', () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(screen.getByRole('status')).toBeVisible()); - expect(fetchMock).toHaveBeenCalledWith( - '/api/settings/modules/users', - expect.objectContaining({ body: JSON.stringify({ retention_days: 30 }) }), - ); + const [req] = fetchMock.mock.calls[0] as unknown as [Request]; + expect(req.method).toBe('PUT'); + expect(new URL(req.url).pathname).toBe('/api/settings/modules/users'); + expect(await req.json()).toEqual({ retention_days: 30 }); expect(mocks.reload).not.toHaveBeenCalled(); expect(screen.getByRole('status')).toHaveTextContent('Settings saved'); }); @@ -91,11 +91,10 @@ describe('ModuleForm', () => { ])('shows %s error on its field', async (_n, loc, msg) => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 422, - json: async () => ({ detail: [{ loc, msg, type: 'x' }] }), - }), + vi.fn( + async () => + new Response(JSON.stringify({ detail: [{ loc, msg, type: 'x' }] }), { status: 422 }), + ), ); render(); fireEvent.change(screen.getByLabelText('retention_days'), { target: { value: '30' } }); @@ -112,11 +111,13 @@ describe('ModuleForm', () => { test('shows an error whose loc names no field at form level', async () => { vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: false, - status: 422, - json: async () => ({ detail: [{ loc: ['value'], msg: 'String too long', type: 'x' }] }), - }), + vi.fn( + async () => + new Response( + JSON.stringify({ detail: [{ loc: ['value'], msg: 'String too long', type: 'x' }] }), + { status: 422 }, + ), + ), ); render(); fireEvent.change(screen.getByLabelText('retention_days'), { target: { value: '30' } }); @@ -203,7 +204,7 @@ describe('Revert', () => { }); test('clears the override and reloads the props', async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + const fetchMock = vi.fn(async () => new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); render( { fireEvent.click(screen.getByRole('button', { name: 'Revert' })); await waitFor(() => expect(mocks.reload).toHaveBeenCalled()); - expect(fetchMock).toHaveBeenCalledWith('/api/settings/modules/users/smtp_host', { - method: 'DELETE', - }); + const [req] = fetchMock.mock.calls[0] as unknown as [Request]; + expect(req.method).toBe('DELETE'); + expect(new URL(req.url).pathname).toBe('/api/settings/modules/users/smtp_host'); }); }); diff --git a/modules/settings/tests-js/settings_TestConnectionButton.test.tsx b/modules/settings/tests-js/settings_TestConnectionButton.test.tsx new file mode 100644 index 000000000..6adb03f0e --- /dev/null +++ b/modules/settings/tests-js/settings_TestConnectionButton.test.tsx @@ -0,0 +1,47 @@ +import '@testing-library/jest-dom/vitest'; +import { configureI18n, keys } from '@simple-module-py/i18n'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +configureI18n({ locale: 'en', messages: {} }); + +import { TestConnectionButton } from '../settings/pages/components/TestConnectionButton'; + +function stub(status: number, body: unknown) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +const click = () => + fireEvent.click( + screen.getByRole('button', { name: keys.settings.modules.test_connection_generic }), + ); + +describe('TestConnectionButton', () => { + afterEach(() => { + vi.unstubAllGlobals(); + window.sessionStorage.clear(); + }); + + it('POSTs to the view route and shows each check detail', async () => { + const fetchMock = stub(200, { + checks: [{ name: 'users.mailer', status: 'unhealthy', detail: 'connection refused' }], + }); + render(); + click(); + expect(await screen.findByText('connection refused')).toBeInTheDocument(); + const [req] = fetchMock.mock.calls[0] as unknown as [Request]; + expect(req.method).toBe('POST'); + expect(new URL(req.url).pathname).toBe('/admin/settings/test-connection/users'); + }); + + it('shows a translated line when the call itself fails', async () => { + stub(500, null); + render(); + click(); + await waitFor(() => + expect(screen.getByText(keys.settings.modules.test_failed)).toBeInTheDocument(), + ); + }); +}); diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 0ebe0d225..756148a0e 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -796,6 +796,7 @@ export default { 'settings.modules.table.value': '', 'settings.modules.test_connection': '', 'settings.modules.test_connection_generic': '', + 'settings.modules.test_failed': '', 'settings.modules.testing': '', 'settings.modules.title': '', 'settings.modules.unsaved_count_one': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index f471008e6..5f4b30f87 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -1024,6 +1024,7 @@ export const keys = { }, test_connection: 'settings.modules.test_connection', test_connection_generic: 'settings.modules.test_connection_generic', + test_failed: 'settings.modules.test_failed', testing: 'settings.modules.testing', title: 'settings.modules.title', unsaved_count: 'settings.modules.unsaved_count', From 1502fe94d6749834c844dcdcc7dce89059a24ed3 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 13:46:08 +0200 Subject: [PATCH 04/16] file_storage pages: typed client for file actions (useFileActions), apiFetch for resumable uploads; Browse split (#504 #522) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../file_storage/pages/Browse.tsx | 104 +- .../file_storage/pages/api.generated.ts | 1052 +++++++++++++++++ .../file_storage/file_storage/pages/api.ts | 9 + .../pages/components/BrowseEmpty.tsx | 36 + .../file_storage/pages/resumable-upload.ts | 39 +- .../file_storage/pages/use-file-actions.ts | 77 ++ .../file_storage_useFileActions.test.tsx | 86 ++ .../tests-js/resumable-upload.test.ts | 12 +- 8 files changed, 1303 insertions(+), 112 deletions(-) create mode 100644 modules/file_storage/file_storage/pages/api.generated.ts create mode 100644 modules/file_storage/file_storage/pages/api.ts create mode 100644 modules/file_storage/file_storage/pages/components/BrowseEmpty.tsx create mode 100644 modules/file_storage/file_storage/pages/use-file-actions.ts create mode 100644 modules/file_storage/tests-js/file_storage_useFileActions.test.tsx diff --git a/modules/file_storage/file_storage/pages/Browse.tsx b/modules/file_storage/file_storage/pages/Browse.tsx index e4229aa9a..472e21e64 100644 --- a/modules/file_storage/file_storage/pages/Browse.tsx +++ b/modules/file_storage/file_storage/pages/Browse.tsx @@ -1,26 +1,27 @@ import { Head, router } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActionDialog'; -import { EmptyState } from '@simple-module-py/ui/components/EmptyState'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Card } from '@simple-module-py/ui/components/ui/card'; import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions'; import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout'; -import { FileBox, Trash2 } from 'lucide-react'; +import { Trash2 } from 'lucide-react'; import { useCallback, useState } from 'react'; import { toast } from 'sonner'; +import { BrowseEmpty } from './components/BrowseEmpty'; import { DropzoneStrip } from './components/DropzoneStrip'; import { FileFilterBar } from './components/FileFilterBar'; import { FileTable } from './components/FileTable'; import { SelectionFooter } from './components/SelectionFooter'; import { UploadDropzone } from './components/UploadDropzone'; import { UploadsCard } from './components/UploadsCard'; -import { PERMISSIONS, RELOAD_PROPS, ROUTES } from './constants'; +import { PERMISSIONS, ROUTES } from './constants'; import { describeTypes, formatBytes } from './format'; -import type { BrowseProps, FileFilters, StoredFile } from './types'; +import type { BrowseProps, FileFilters } from './types'; import { useUploadQueue } from './upload-queue'; +import { useFileActions } from './use-file-actions'; function Browse(props: BrowseProps) { const { files, pagination, content_types: contentTypes, uploaders } = props; @@ -55,8 +56,11 @@ function Browse(props: BrowseProps) { // one: rows ingested before a backend switch still live where they landed. const confirmBackend = selectedFiles.length === 1 ? selectedFiles[0].backend : props.backend; - const [confirming, setConfirming] = useState(false); - const [deleting, setDeleting] = useState(false); + const { confirming, setConfirming, deleting, togglePublic, deleteSelected } = useFileActions({ + files, + selectedIds, + clearSelection: () => select([]), + }); const navigate = useCallback((next: FileFilters, target = 1) => { const params: Record = {}; @@ -88,65 +92,6 @@ function Browse(props: BrowseProps) { } } - async function handleTogglePublic(file: StoredFile) { - try { - const resp = await fetch(ROUTES.apiFile(file.id), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ public: !file.public }), - }); - if (!resp.ok) throw new Error('visibility change failed'); - toast.success( - t( - file.public - ? keys.file_storage.toasts.made_private - : keys.file_storage.toasts.made_public, - { - name: file.filename, - }, - ), - ); - router.reload({ only: RELOAD_PROPS }); - } catch { - toast.error(t(keys.file_storage.toasts.visibility_failed)); - } - } - - async function handleDelete() { - setDeleting(true); - try { - const resp = await fetch(ROUTES.API_BULK_DELETE, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ids: selectedIds }), - }); - if (!resp.ok) throw new Error('bulk delete failed'); - const { deleted, ids } = (await resp.json()) as { deleted: number; ids: string[] }; - // Named from what the server *removed*, not from what was selected: part - // of a selection can already be gone, and naming the first file the user - // ticked would credit a deletion that did not happen. - const removed = ids.length === 1 ? files.find((f) => f.id === ids[0]) : undefined; - const requested = selectedIds.length; - if (deleted === 0) { - // A stale tab: every selected file was removed elsewhere already. - toast.info(t(keys.file_storage.toasts.already_deleted, { count: requested })); - } else if (deleted < requested) { - toast.success(t(keys.file_storage.toasts.deleted_partial, { deleted, total: requested })); - } else { - toast.success( - t(keys.file_storage.toasts.deleted, { count: deleted, name: removed?.filename ?? '' }), - ); - } - select([]); - router.reload({ only: RELOAD_PROPS }); - } catch { - toast.error(t(keys.file_storage.toasts.delete_failed)); - } finally { - setDeleting(false); - setConfirming(false); - } - } - const maxSize = formatBytes(props.max_file_size_bytes); const subtitle = props.quota_bytes ? t(keys.file_storage.browse.subtitle_quota, { @@ -222,35 +167,16 @@ function Browse(props: BrowseProps) { selectedIds={selectedIds} canDelete={canDelete} canPublish={canUpload} - onTogglePublic={handleTogglePublic} + onTogglePublic={togglePublic} onToggleRow={(id, on) => select(on ? [...selectedIds, id] : selectedIds.filter((x) => x !== id)) } onToggleAll={(on) => select(on ? files.map((f) => f.id) : [])} empty={ showEmpty ? ( - applyFilters({ q: '', content_type: '', uploaded_by: '' })} - > - {t(keys.file_storage.browse.clear_filters)} - - ) : undefined - } + applyFilters({ q: '', content_type: '', uploaded_by: '' })} /> ) : null } @@ -286,7 +212,7 @@ function Browse(props: BrowseProps) { })} cancelLabel={t(keys.file_storage.delete_dialog.cancel)} busy={deleting} - onConfirm={handleDelete} + onConfirm={deleteSelected} /> diff --git a/modules/file_storage/file_storage/pages/api.generated.ts b/modules/file_storage/file_storage/pages/api.generated.ts new file mode 100644 index 000000000..3f6d36738 --- /dev/null +++ b/modules/file_storage/file_storage/pages/api.generated.ts @@ -0,0 +1,1052 @@ +// AUTO-GENERATED by `smpy openapi gen-types` from the FileStorage module's /api +// operations — do not edit. Regenerate with `smpy openapi gen-types --module file_storage` +// (`make gen-api-types` in the framework repo). +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/file-storage/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Upload File */ + post: operations["file_storage_upload_file"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/files": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Files */ + get: operations["file_storage_list_files"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/files/{file_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get File */ + get: operations["file_storage_get_file"]; + put?: never; + post?: never; + /** Delete File */ + delete: operations["file_storage_delete_file"]; + options?: never; + head?: never; + /** + * Update File + * @description Publish or unpublish a file the caller uploaded (or any, with manage). + */ + patch: operations["file_storage_update_file"]; + trace?: never; + }; + "/api/file-storage/files/{file_id}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** File Thumbnail */ + get: operations["file_storage_file_thumbnail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/files/{file_id}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Download File */ + get: operations["file_storage_download_file"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/files/bulk-delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Bulk Delete Files + * @description Delete a selection in one request. + * + * Ids that no longer resolve are skipped rather than 404-ing the batch — the + * screen's selection can outlive the rows it names. The response names the + * rows that actually went, so the caller can report on them rather than on + * what it asked for. Each removal is still announced individually, so a + * subscriber that mirrors or reindexes files cannot tell a bulk delete from a + * run of single ones. + */ + post: operations["file_storage_bulk_delete_files"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Session */ + post: operations["file_storage_create_session"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/uploads/{upload_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Session */ + get: operations["file_storage_get_session"]; + put?: never; + post?: never; + /** Abort Session */ + delete: operations["file_storage_abort_session"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/uploads/{upload_id}/part-urls": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Part Urls */ + post: operations["file_storage_part_urls"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/uploads/{upload_id}/parts/{part_no}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Put Part */ + put: operations["file_storage_put_part"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/uploads/{upload_id}/complete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Complete Session */ + post: operations["file_storage_complete_session"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/public/{file_id}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public Thumbnail */ + get: operations["file_storage_public_thumbnail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/public/{file_id}/{filename}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public File */ + get: operations["file_storage_public_file"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/file-storage/public/{file_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public File */ + get: operations["file_storage_public_file_get_api_file_storage_public_file_id"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** Body_file_storage_upload_file */ + Body_file_storage_upload_file: { + /** File */ + file: string; + /** + * Public + * @default false + */ + public: boolean; + }; + /** + * BulkDeleteRequest + * @description Body for POST /api/file-storage/files/bulk-delete. + */ + BulkDeleteRequest: { + /** Ids */ + ids?: string[]; + }; + /** + * BulkDeleteResult + * @description Which rows the batch actually removed. + * + * Not simply an echo of the request: a selection can name files another admin + * has already deleted. The caller needs the ids, not only the count — a + * screen that names "the" deleted file from its own selection would credit + * the wrong filename whenever part of the batch had already gone. + */ + BulkDeleteResult: { + /** Deleted */ + deleted: number; + /** Ids */ + ids?: string[]; + }; + /** CompletePart */ + CompletePart: { + /** N */ + n: number; + /** Etag */ + etag: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * PartOut + * @description One part the backend holds. + */ + PartOut: { + /** No */ + no: number; + /** Etag */ + etag: string; + /** Size */ + size: number; + }; + /** + * PartUrlsOut + * @description Where to PUT each requested part; ``direct`` is false for app-relayed parts. + */ + PartUrlsOut: { + /** Urls */ + urls: { + [key: string]: string; + }; + /** Direct */ + direct: boolean; + }; + /** + * PartUrlsRequest + * @description Body for POST /uploads/{id}/part-urls. + */ + PartUrlsRequest: { + /** Parts */ + parts: number[]; + }; + /** + * StoredFileListOut + * @description Paginated list response. + */ + StoredFileListOut: { + /** Items */ + items: components["schemas"]["StoredFileOut"][]; + /** Total */ + total: number; + /** Page */ + page: number; + /** Per Page */ + per_page: number; + }; + /** + * StoredFileOut + * @description Metadata returned for a stored file. + */ + StoredFileOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** Key */ + key: string; + /** Filename */ + filename: string; + /** Content Type */ + content_type: string; + /** Size Bytes */ + size_bytes: number; + /** Backend */ + backend: string; + /** + * Checksum Sha256 + * @description SHA-256, or null for a multipart upload not yet hashed. + */ + checksum_sha256?: string | null; + /** + * Uploaded By + * @description User id from AuditMixin.created_by — populated by the audit listener. + */ + uploaded_by?: string | null; + /** Created At */ + created_at?: string | null; + /** + * Public + * @description Whether anonymous visitors may fetch it. + * @default false + */ + public: boolean; + /** + * Public Url + * @description Anonymous URL, present only while the file is public. + */ + public_url?: string | null; + }; + /** + * StoredFileUpdate + * @description Body for PATCH /api/file-storage/files/{id}. + */ + StoredFileUpdate: { + /** Public */ + public: boolean; + }; + /** + * UploadCompleteRequest + * @description Body for POST /uploads/{id}/complete: the parts the client believes it sent. + */ + UploadCompleteRequest: { + /** Parts */ + parts?: components["schemas"]["CompletePart"][]; + }; + /** + * UploadSessionCreate + * @description Body for POST /api/file-storage/uploads. + */ + UploadSessionCreate: { + /** Filename */ + filename: string; + /** + * Content Type + * @default application/octet-stream + */ + content_type: string; + /** Total Size */ + total_size: number; + /** + * Public + * @default false + */ + public: boolean; + }; + /** + * UploadSessionOut + * @description A resumable upload: what it is, and which parts the backend already has. + */ + UploadSessionOut: { + /** + * Id + * Format: uuid + */ + id: string; + /** Filename */ + filename: string; + /** Content Type */ + content_type: string; + /** Total Size */ + total_size: number; + /** Part Size */ + part_size: number; + /** Total Parts */ + total_parts: number; + /** Status */ + status: string; + /** Public */ + public: boolean; + /** + * Expires At + * Format: date-time + */ + expires_at: string; + /** Parts */ + parts?: components["schemas"]["PartOut"][]; + /** @description The stored file, once the session is completed. */ + file?: components["schemas"]["StoredFileOut"] | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + file_storage_upload_file: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_file_storage_upload_file"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StoredFileOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_list_files: { + parameters: { + query?: { + /** @description Case-insensitive substring of the original filename. */ + q?: string | null; + /** @description Exact content type, or a family ending in '/' such as 'image/'. */ + content_type?: string | null; + sort?: "created_at" | "-created_at" | "name" | "-name" | "size" | "-size"; + page?: number; + per_page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StoredFileListOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_get_file: { + parameters: { + query?: never; + header?: never; + path: { + file_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StoredFileOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_delete_file: { + parameters: { + query?: never; + header?: never; + path: { + file_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_update_file: { + parameters: { + query?: never; + header?: never; + path: { + file_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StoredFileUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StoredFileOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_file_thumbnail: { + parameters: { + query?: { + /** @description Width in px; clamped and snapped. */ + w?: number | null; + }; + header?: never; + path: { + file_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_download_file: { + parameters: { + query?: never; + header?: never; + path: { + file_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_bulk_delete_files: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BulkDeleteRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkDeleteResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_create_session: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UploadSessionCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadSessionOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_get_session: { + parameters: { + query?: never; + header?: never; + path: { + upload_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadSessionOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_abort_session: { + parameters: { + query?: never; + header?: never; + path: { + upload_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_part_urls: { + parameters: { + query?: never; + header?: never; + path: { + upload_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PartUrlsRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PartUrlsOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_put_part: { + parameters: { + query?: never; + header?: never; + path: { + upload_id: string; + part_no: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PartOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_complete_session: { + parameters: { + query?: never; + header?: never; + path: { + upload_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UploadCompleteRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UploadSessionOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_public_thumbnail: { + parameters: { + query?: { + /** @description Width in px; clamped and snapped. */ + w?: number | null; + }; + header?: never; + path: { + file_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_public_file: { + parameters: { + query?: never; + header?: never; + path: { + file_id: string; + filename: string | null; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + file_storage_public_file_get_api_file_storage_public_file_id: { + parameters: { + query?: { + filename?: string | null; + }; + header?: never; + path: { + file_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/modules/file_storage/file_storage/pages/api.ts b/modules/file_storage/file_storage/pages/api.ts new file mode 100644 index 000000000..14977bec6 --- /dev/null +++ b/modules/file_storage/file_storage/pages/api.ts @@ -0,0 +1,9 @@ +/** + * Typed client for this module's `/api` routes (#504). The `paths` types are + * generated from the routes — regenerate with `make gen-api-types` (or + * `smpy openapi gen-types --module file_storage`), never edit them by hand. + */ +import { createApi } from '@simple-module-py/ui/lib/api'; +import type { paths } from './api.generated'; + +export const api = createApi(); diff --git a/modules/file_storage/file_storage/pages/components/BrowseEmpty.tsx b/modules/file_storage/file_storage/pages/components/BrowseEmpty.tsx new file mode 100644 index 000000000..cd23ab70f --- /dev/null +++ b/modules/file_storage/file_storage/pages/components/BrowseEmpty.tsx @@ -0,0 +1,36 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { EmptyState } from '@simple-module-py/ui/components/EmptyState'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { FileBox } from 'lucide-react'; + +interface Props { + isFiltered: boolean; + onClear: () => void; +} + +/** The table's empty state: an empty bucket, or a filter that matched nothing. */ +export function BrowseEmpty({ isFiltered, onClear }: Props) { + const { t } = useT(); + return ( + + {t(keys.file_storage.browse.clear_filters)} + + ) : undefined + } + /> + ); +} diff --git a/modules/file_storage/file_storage/pages/resumable-upload.ts b/modules/file_storage/file_storage/pages/resumable-upload.ts index d84ae77f7..3d0c42082 100644 --- a/modules/file_storage/file_storage/pages/resumable-upload.ts +++ b/modules/file_storage/file_storage/pages/resumable-upload.ts @@ -1,3 +1,4 @@ +import { apiFetch, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; import { clearSessionId, loadSessionId, saveSessionId } from './resume-store'; import { parseFailureReason, UploadAbortedError, UploadFailedError } from './upload-errors'; @@ -30,20 +31,22 @@ export interface RunOptions { backoffMs?: number; } -async function api(method: string, url: string, body?: unknown): Promise { - let response: Response; - try { - response = await fetch(url, { - method, - headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - } catch { - throw new UploadFailedError(undefined); +/** + * One JSON call to the uploads API. These routes are addressed dynamically + * (session id in the path, one helper for every verb), so they go through the + * untyped `apiFetch`; a failure becomes an `UploadFailedError` carrying the + * status (`undefined` when the request never reached the server) and the + * server's own reason. + */ +async function call(method: string, url: string, json?: unknown): Promise { + const result = await apiFetch(url, { method, json }); + if (!result.ok) { + throw new UploadFailedError( + result.status === NETWORK_FAILURE ? undefined : result.status, + parseFailureReason(JSON.stringify(result.error)), + ); } - const text = await response.text(); - if (!response.ok) throw new UploadFailedError(response.status, parseFailureReason(text)); - return (text ? JSON.parse(text) : undefined) as T; + return result.data; } /** The stored session for this file, if the server still has it open. */ @@ -51,7 +54,7 @@ export async function findSession(file: File): Promise { const id = loadSessionId(file); if (!id) return null; try { - const session = await api('GET', `${BASE}/${id}`); + const session = await call('GET', `${BASE}/${id}`); if (session.status === 'active' && session.total_size === file.size) return session; } catch (error) { // A network blip says nothing about the session; keep the id for next time. @@ -65,14 +68,14 @@ export async function findSession(file: File): Promise { export async function discardSession(file: File, id: string): Promise { clearSessionId(file); try { - await api('DELETE', `${BASE}/${id}`); + await call('DELETE', `${BASE}/${id}`); } catch { // The reaper collects it at expiry. } } function createSession(file: File): Promise { - return api('POST', BASE, { + return call('POST', BASE, { filename: file.name, content_type: file.type || 'application/octet-stream', total_size: file.size, @@ -182,7 +185,7 @@ export async function runResumable( for (let attempt = 0; ; attempt += 1) { if (inner.signal.aborted) throw new UploadAbortedError(); try { - const { urls } = await api<{ urls: Record }>( + const { urls } = await call<{ urls: Record }>( 'POST', `${BASE}/${session.id}/part-urls`, { parts: [n] }, @@ -210,7 +213,7 @@ export async function runResumable( try { await Promise.all(Array.from({ length: PART_CONCURRENCY }, worker)); - await api('POST', `${BASE}/${session.id}/complete`, { + await call('POST', `${BASE}/${session.id}/complete`, { parts: [...etags].filter(([, etag]) => etag).map(([n, etag]) => ({ n, etag })), }); } catch (error) { diff --git a/modules/file_storage/file_storage/pages/use-file-actions.ts b/modules/file_storage/file_storage/pages/use-file-actions.ts new file mode 100644 index 000000000..dd3636fa0 --- /dev/null +++ b/modules/file_storage/file_storage/pages/use-file-actions.ts @@ -0,0 +1,77 @@ +import { router } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { apiResult } from '@simple-module-py/ui/lib/api'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { api } from './api'; +import { RELOAD_PROPS } from './constants'; +import type { StoredFile } from './types'; + +interface Options { + files: StoredFile[]; + selectedIds: string[]; + /** Called after a bulk delete so the selection does not name removed rows. */ + clearSelection: () => void; +} + +/** The mutations Browse can make: flip a file's visibility, delete the selection. */ +export function useFileActions({ files, selectedIds, clearSelection }: Options) { + const { t } = useT(); + const [confirming, setConfirming] = useState(false); + const [deleting, setDeleting] = useState(false); + + async function togglePublic(file: StoredFile) { + const result = await apiResult( + api.PATCH('/api/file-storage/files/{file_id}', { + params: { path: { file_id: file.id } }, + body: { public: !file.public }, + }), + ); + if (!result.ok) { + toast.error(t(keys.file_storage.toasts.visibility_failed)); + return; + } + toast.success( + t( + file.public ? keys.file_storage.toasts.made_private : keys.file_storage.toasts.made_public, + { + name: file.filename, + }, + ), + ); + router.reload({ only: RELOAD_PROPS }); + } + + async function deleteSelected() { + setDeleting(true); + const result = await apiResult( + api.POST('/api/file-storage/files/bulk-delete', { body: { ids: selectedIds } }), + ); + if (!result.ok) { + toast.error(t(keys.file_storage.toasts.delete_failed)); + } else { + const { deleted, ids = [] } = result.data; + // Named from what the server *removed*, not from what was selected: part + // of a selection can already be gone, and naming the first file the user + // ticked would credit a deletion that did not happen. + const removed = ids.length === 1 ? files.find((f) => f.id === ids[0]) : undefined; + const requested = selectedIds.length; + if (deleted === 0) { + // A stale tab: every selected file was removed elsewhere already. + toast.info(t(keys.file_storage.toasts.already_deleted, { count: requested })); + } else if (deleted < requested) { + toast.success(t(keys.file_storage.toasts.deleted_partial, { deleted, total: requested })); + } else { + toast.success( + t(keys.file_storage.toasts.deleted, { count: deleted, name: removed?.filename ?? '' }), + ); + } + clearSelection(); + router.reload({ only: RELOAD_PROPS }); + } + setDeleting(false); + setConfirming(false); + } + + return { confirming, setConfirming, deleting, togglePublic, deleteSelected }; +} diff --git a/modules/file_storage/tests-js/file_storage_useFileActions.test.tsx b/modules/file_storage/tests-js/file_storage_useFileActions.test.tsx new file mode 100644 index 000000000..1e8f72641 --- /dev/null +++ b/modules/file_storage/tests-js/file_storage_useFileActions.test.tsx @@ -0,0 +1,86 @@ +import { configureI18n, keys } from '@simple-module-py/i18n'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +configureI18n({ locale: 'en', messages: {} }); + +const reload = vi.hoisted(() => vi.fn()); +vi.mock('@inertiajs/react', () => ({ router: { reload } })); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); + +import { toast } from 'sonner'; +import type { StoredFile } from '../file_storage/pages/types'; +import { useFileActions } from '../file_storage/pages/use-file-actions'; + +const files = [ + { id: 'a', filename: 'a.txt', public: false }, + { id: 'b', filename: 'b.txt', public: true }, +] as StoredFile[]; + +function stub(status: number, body: unknown) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function setup(selectedIds: string[]) { + const clearSelection = vi.fn(); + const hook = renderHook(() => useFileActions({ files, selectedIds, clearSelection })); + return { ...hook, clearSelection }; +} + +describe('useFileActions', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('PATCHes the flipped visibility and reloads', async () => { + const fetchMock = stub(200, {}); + const { result } = setup([]); + await act(() => result.current.togglePublic(files[0])); + const [req] = fetchMock.mock.calls[0] as unknown as [Request]; + expect(req.method).toBe('PATCH'); + expect(new URL(req.url).pathname).toBe('/api/file-storage/files/a'); + expect(await req.json()).toEqual({ public: true }); + expect(toast.success).toHaveBeenCalledWith(keys.file_storage.toasts.made_public); + expect(reload).toHaveBeenCalled(); + }); + + it('toasts and does not reload when the visibility change fails', async () => { + stub(403, { detail: 'no' }); + const { result } = setup([]); + await act(() => result.current.togglePublic(files[0])); + expect(toast.error).toHaveBeenCalledWith(keys.file_storage.toasts.visibility_failed); + expect(reload).not.toHaveBeenCalled(); + }); + + it('bulk deletes the selection, clears it and closes the dialog', async () => { + const fetchMock = stub(200, { deleted: 2, ids: ['a', 'b'] }); + const { result, clearSelection } = setup(['a', 'b']); + act(() => result.current.setConfirming(true)); + await act(() => result.current.deleteSelected()); + const [req] = fetchMock.mock.calls[0] as unknown as [Request]; + expect(new URL(req.url).pathname).toBe('/api/file-storage/files/bulk-delete'); + expect(await req.json()).toEqual({ ids: ['a', 'b'] }); + expect(clearSelection).toHaveBeenCalled(); + expect(result.current.confirming).toBe(false); + expect(result.current.deleting).toBe(false); + }); + + it('reports a partial delete from what the server removed', async () => { + stub(200, { deleted: 1, ids: ['a'] }); + const { result } = setup(['a', 'b']); + await act(() => result.current.deleteSelected()); + expect(toast.success).toHaveBeenCalledWith(keys.file_storage.toasts.deleted_partial); + }); + + it('keeps the selection when the delete fails', async () => { + stub(500, null); + const { result, clearSelection } = setup(['a']); + await act(() => result.current.deleteSelected()); + expect(toast.error).toHaveBeenCalledWith(keys.file_storage.toasts.delete_failed); + expect(clearSelection).not.toHaveBeenCalled(); + expect(result.current.confirming).toBe(false); + }); +}); diff --git a/modules/file_storage/tests-js/resumable-upload.test.ts b/modules/file_storage/tests-js/resumable-upload.test.ts index 6d71ebf94..39ba8a0b6 100644 --- a/modules/file_storage/tests-js/resumable-upload.test.ts +++ b/modules/file_storage/tests-js/resumable-upload.test.ts @@ -29,8 +29,10 @@ class FakeServer { completeStatus = 200; total = 0; - fetch = async (url: string, init: RequestInit = {}): Promise => { - const method = init.method ?? 'GET'; + fetch = async (request: Request): Promise => { + const method = request.method; + const url = new URL(request.url).pathname; + const init = { body: await request.text() }; this.calls.push(`${method} ${url}`); const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status }); if (method === 'POST' && url === '/api/file-storage/uploads') { @@ -150,9 +152,9 @@ describe('runResumable', () => { let seen: string | null = null; server.putStatuses.set(1, [200]); const original = server.fetch; - vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => { - if (url.endsWith('/complete')) seen = window.localStorage.getItem(key); - return original(url, init); + vi.stubGlobal('fetch', async (request: Request) => { + if (request.url.endsWith('/complete')) seen = window.localStorage.getItem(key); + return original(request); }); await runResumable(file, OPTIONS()); From 31d70a9b6da6f1426b6994a6ebabf616821fe212 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 13:47:17 +0200 Subject: [PATCH 05/16] setup wizard: apiFetch for /setup/* calls, keep page-prop CSRF token (#504) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../simple_module_hosting/locales/en.json | 1 + .../components/ConnectionList.tsx | 26 ++---- .../setup_wizard/components/StepForm.tsx | 37 +++----- .../tests-js/hosting_SetupWizard.test.tsx | 93 +++++++++++++++++++ packages/i18n/src/generated-resources.ts | 1 + packages/i18n/src/keys.generated.ts | 1 + vitest.config.mts | 2 + 7 files changed, 123 insertions(+), 38 deletions(-) create mode 100644 framework/hosting/tests-js/hosting_SetupWizard.test.tsx diff --git a/framework/hosting/simple_module_hosting/locales/en.json b/framework/hosting/simple_module_hosting/locales/en.json index a1acbd149..836d5b3b5 100644 --- a/framework/hosting/simple_module_hosting/locales/en.json +++ b/framework/hosting/simple_module_hosting/locales/en.json @@ -18,6 +18,7 @@ "description": "Bring the database schema up to the version this code expects." } }, + "request_failed": "The request failed. Try again.", "working": "Working…", "done": "Done. Reloading…" }, diff --git a/framework/hosting/simple_module_hosting/setup_wizard/components/ConnectionList.tsx b/framework/hosting/simple_module_hosting/setup_wizard/components/ConnectionList.tsx index 79e930165..5dbdace27 100644 --- a/framework/hosting/simple_module_hosting/setup_wizard/components/ConnectionList.tsx +++ b/framework/hosting/simple_module_hosting/setup_wizard/components/ConnectionList.tsx @@ -1,5 +1,6 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; +import { apiFetch } from '@simple-module-py/ui/lib/api'; import { CheckCircle2, PlugZap, XCircle } from 'lucide-react'; import { useState } from 'react'; @@ -32,22 +33,15 @@ export function ConnectionList({ async function retest() { setBusy(true); setError(null); - try { - const resp = await fetch('/setup/test-connections', { - method: 'POST', - headers: { Accept: 'application/json', 'X-CSRF-Token': csrfToken }, - }); - // A 404 here means setup completed in another tab, and the body is not - // the JSON this expects. Without the check, `resp.json()` throws into an - // unhandled rejection and the button just stops responding. - if (!resp.ok) throw new Error(resp.statusText || String(resp.status)); - const body = await resp.json(); - setChecks(body.checks ?? []); - } catch (err) { - setError((err as Error).message); - } finally { - setBusy(false); - } + // A 404 here means setup completed in another tab; it arrives as a failed + // result instead of a JSON parse error. + const result = await apiFetch<{ checks?: CheckResult[] }>('/setup/test-connections', { + method: 'POST', + headers: { 'X-CSRF-Token': csrfToken }, + }); + if (result.ok) setChecks(result.data?.checks ?? []); + else setError(result.detail ?? t(keys.hosting.setup.request_failed)); + setBusy(false); } return ( diff --git a/framework/hosting/simple_module_hosting/setup_wizard/components/StepForm.tsx b/framework/hosting/simple_module_hosting/setup_wizard/components/StepForm.tsx index cbd5084fe..a65d6794d 100644 --- a/framework/hosting/simple_module_hosting/setup_wizard/components/StepForm.tsx +++ b/framework/hosting/simple_module_hosting/setup_wizard/components/StepForm.tsx @@ -2,6 +2,7 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Input } from '@simple-module-py/ui/components/ui/input'; import { Label } from '@simple-module-py/ui/components/ui/label'; +import { apiFetch } from '@simple-module-py/ui/lib/api'; import { useState } from 'react'; /** One input of a step's form — `SetupField` on the server. */ @@ -75,31 +76,23 @@ export function StepForm({ data[field.name] = typeof value === 'string' && value !== '' ? value : null; } - try { - const resp = await fetch(`/setup/steps/${encodeURIComponent(stepId)}`, { - method: 'POST', - // Accept: application/json matters. The host renders an Inertia error - // *page* for a 4xx unless the caller prefers JSON, and a bare fetch() - // sends Accept: */* — so without it the body is HTML, json() throws, - // and the operator never sees why the request was refused. - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-CSRF-Token': csrfToken, - }, - body: JSON.stringify(data), - }); - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - throw new Error(errorMessage(body, resp.statusText || String(resp.status))); - } + // `/setup/*` is outside the OpenAPI document and its token is the page + // prop, not the session one, so it is passed explicitly (`apiFetch` leaves + // a header the caller set alone). `apiFetch` sends Accept: application/json, + // which matters: the host renders an Inertia error *page* for a 4xx unless + // the caller prefers JSON, and the operator would never see why. + const result = await apiFetch(`/setup/steps/${encodeURIComponent(stepId)}`, { + method: 'POST', + headers: { 'X-CSRF-Token': csrfToken }, + json: data, + }); + if (!result.ok) { + setError(errorMessage(result.error, t(keys.hosting.setup.request_failed))); + } else { setDone(true); window.location.href = '/'; - } catch (err) { - setError((err as Error).message); - } finally { - setBusy(false); } + setBusy(false); } return ( diff --git a/framework/hosting/tests-js/hosting_SetupWizard.test.tsx b/framework/hosting/tests-js/hosting_SetupWizard.test.tsx new file mode 100644 index 000000000..333fd54f9 --- /dev/null +++ b/framework/hosting/tests-js/hosting_SetupWizard.test.tsx @@ -0,0 +1,93 @@ +import '@testing-library/jest-dom/vitest'; +import { configureI18n, keys } from '@simple-module-py/i18n'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +configureI18n({ locale: 'en', messages: {} }); + +import { ConnectionList } from '../simple_module_hosting/setup_wizard/components/ConnectionList'; +import { StepForm } from '../simple_module_hosting/setup_wizard/components/StepForm'; + +function stub(status: number, body: unknown) { + const fetchMock = vi.fn(async () => new Response(JSON.stringify(body), { status })); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +const action = { + submitLabel: 'Create admin', + fields: [ + { + name: 'email', + label: 'Email', + type: 'text', + required: false, + autocomplete: '', + minLength: null, + }, + { + name: 'note', + label: 'Note', + type: 'text', + required: false, + autocomplete: '', + minLength: null, + }, + ], +}; + +describe('StepForm', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('POSTs the fields as JSON with the page-prop CSRF token', async () => { + const fetchMock = stub(200, {}); + render(); + fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'a@b.co' } }); + fireEvent.submit(screen.getByRole('button', { name: 'Create admin' }).closest('form')!); + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const [req] = fetchMock.mock.calls[0] as unknown as [Request]; + expect(req.method).toBe('POST'); + expect(new URL(req.url).pathname).toBe('/setup/steps/admin%20user'); + expect(req.headers.get('X-CSRF-Token')).toBe('page-token'); + expect(req.headers.get('Accept')).toBe('application/json'); + expect(await req.json()).toEqual({ email: 'a@b.co', note: null }); + }); + + it('joins the messages of a 422 detail list', async () => { + stub(422, { detail: [{ msg: 'too short' }, { msg: 'bad address' }] }); + render(); + fireEvent.submit(screen.getByRole('button', { name: 'Create admin' }).closest('form')!); + expect(await screen.findByText('too short; bad address')).toBeInTheDocument(); + }); + + it('falls back to a translated line when the body has no detail', async () => { + stub(500, null); + render(); + fireEvent.submit(screen.getByRole('button', { name: 'Create admin' }).closest('form')!); + expect(await screen.findByText(keys.hosting.setup.request_failed)).toBeInTheDocument(); + }); +}); + +describe('ConnectionList', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('retests with the page-prop token and replaces the list', async () => { + const fetchMock = stub(200, { + checks: [{ name: 'redis', status: 'healthy', detail: 'ok' }], + }); + render(); + fireEvent.click(screen.getByRole('button', { name: keys.hosting.setup.connections.retest })); + expect(await screen.findByText('redis')).toBeInTheDocument(); + const [req] = fetchMock.mock.calls[0] as unknown as [Request]; + expect(new URL(req.url).pathname).toBe('/setup/test-connections'); + expect(req.headers.get('X-CSRF-Token')).toBe('page-token'); + }); + + it('shows an error line when setup finished in another tab (404)', async () => { + stub(404, null); + render(); + fireEvent.click(screen.getByRole('button', { name: keys.hosting.setup.connections.retest })); + expect(await screen.findByText(keys.hosting.setup.request_failed)).toBeInTheDocument(); + }); +}); diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 756148a0e..8825d5593 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -635,6 +635,7 @@ export default { 'hosting.setup.connections.testing': '', 'hosting.setup.done': '', 'hosting.setup.migrations.apply': '', + 'hosting.setup.request_failed': '', 'hosting.setup.steps.heading': '', 'hosting.setup.steps.migrations.description': '', 'hosting.setup.steps.migrations.title': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 5f4b30f87..3b85ccfbf 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -813,6 +813,7 @@ export const keys = { migrations: { apply: 'hosting.setup.migrations.apply', }, + request_failed: 'hosting.setup.request_failed', steps: { heading: 'hosting.setup.steps.heading', migrations: { diff --git a/vitest.config.mts b/vitest.config.mts index 20d345f0a..410cc548d 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -13,6 +13,8 @@ export default defineConfig({ include: [ 'packages/**/*.test.ts', 'packages/**/*.test.tsx', + 'framework/hosting/tests-js/**/*.test.ts', + 'framework/hosting/tests-js/**/*.test.tsx', 'host/client_app/**/*.test.ts', 'host/client_app/**/*.test.tsx', 'modules/**/tests-js/**/*.test.ts', From 0536c545d58f550c5e4a3b7b941b4b83bfea9e7a Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 13:49:33 +0200 Subject: [PATCH 06/16] Users module: migrate raw fetch calls to the typed API client; split AddPeople (#504 #522) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/users/tests-js/users_Login.test.tsx | 94 + ...ister.test.tsx => users_Register.test.tsx} | 0 modules/users/tests-js/users_actions.test.tsx | 88 + modules/users/tests-js/users_fixtures.ts | 23 + .../admin/components/useUserRowActions.ts | 40 +- .../auth_local/components/PasswordCard.tsx | 23 +- .../components/ProfileDetailsCard.tsx | 20 +- .../components/ResendVerification.tsx | 9 +- .../auth_local/components/SessionsCard.tsx | 29 +- modules/users/users/pages/AcceptInvite.tsx | 29 +- modules/users/users/pages/ForgotPassword.tsx | 12 +- modules/users/users/pages/Login.tsx | 64 +- modules/users/users/pages/Register.tsx | 15 +- modules/users/users/pages/ResetPassword.tsx | 36 +- modules/users/users/pages/Users/AddPeople.tsx | 223 +- .../pages/Users/components/DangerZone.tsx | 41 +- .../pages/Users/components/MailerBanner.tsx | 37 + .../pages/Users/components/useAddPeople.ts | 157 ++ .../pages/Users/components/useEditUserForm.ts | 31 +- .../pages/Users/components/useUserActions.ts | 47 +- modules/users/users/pages/VerifyEmail.tsx | 24 +- modules/users/users/pages/api.generated.ts | 1921 +++++++++++++++++ modules/users/users/pages/api.ts | 9 + modules/users/users/pages/failureMessage.ts | 16 + modules/users/users/pages/signIn.ts | 25 + 25 files changed, 2618 insertions(+), 395 deletions(-) create mode 100644 modules/users/tests-js/users_Login.test.tsx rename modules/users/tests-js/{Register.test.tsx => users_Register.test.tsx} (100%) create mode 100644 modules/users/tests-js/users_actions.test.tsx create mode 100644 modules/users/tests-js/users_fixtures.ts create mode 100644 modules/users/users/pages/Users/components/MailerBanner.tsx create mode 100644 modules/users/users/pages/Users/components/useAddPeople.ts create mode 100644 modules/users/users/pages/api.generated.ts create mode 100644 modules/users/users/pages/api.ts create mode 100644 modules/users/users/pages/failureMessage.ts create mode 100644 modules/users/users/pages/signIn.ts diff --git a/modules/users/tests-js/users_Login.test.tsx b/modules/users/tests-js/users_Login.test.tsx new file mode 100644 index 000000000..910066275 --- /dev/null +++ b/modules/users/tests-js/users_Login.test.tsx @@ -0,0 +1,94 @@ +import { configureI18n, keys } from '@simple-module-py/i18n'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const visit = vi.hoisted(() => vi.fn()); +vi.mock('@inertiajs/react', () => ({ + Head: () => null, + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), + router: { visit }, + usePage: () => ({ props: { branding: { appName: 'Acme', logoUrl: null } } }), +})); + +import Login from '../users/pages/Login'; +import { json, sent, stubFetch } from './users_fixtures'; + +configureI18n({ locale: 'en', messages: {} }); + +const props = { + allow_signup: false, + demo_signin: { accounts: [], read_only: false }, + dev_accounts: [], + login_redirect_url: '/dashboard', + oauth_providers: [], + remember_me_days: 30, +}; + +function submit() { + fireEvent.change(document.querySelector('input[type="email"]') as HTMLElement, { + target: { value: 'a@example.com' }, + }); + fireEvent.change(document.querySelector('input[type="password"]') as HTMLElement, { + target: { value: 'secret-pw' }, + }); + fireEvent.submit(document.querySelector('form') as HTMLFormElement); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('users Login', () => { + it('posts the form-encoded credentials and follows the server redirect on 204', async () => { + const mock = stubFetch(() => new Response(null, { status: 204 })); + render(); + submit(); + await waitFor(() => expect(visit).toHaveBeenCalledWith('/dashboard')); + const call = await sent(mock); + expect(call.path).toBe('/api/users/auth/login'); + expect(call.method).toBe('POST'); + expect(call.contentType).toContain('application/x-www-form-urlencoded'); + expect(call.credentials).toBe('same-origin'); + const form = new URLSearchParams(call.text); + expect(form.get('username')).toBe('a@example.com'); + expect(form.get('password')).toBe('secret-pw'); + expect(form.get('remember')).toBe('false'); + }); + + it('shows the rate-limit copy on 429', async () => { + stubFetch(() => json(429, { detail: 'slow down' })); + render(); + submit(); + expect(await screen.findByText(keys.users.login.error_rate_limited)).toBeInTheDocument(); + expect(visit).not.toHaveBeenCalled(); + }); + + it('shows invalid credentials on any other failure', async () => { + stubFetch(() => json(400, { detail: 'LOGIN_BAD_CREDENTIALS' })); + render(); + submit(); + expect(await screen.findByText(keys.users.login.error_invalid_credentials)).toBeInTheDocument(); + }); + + it('says to try again when the request never reaches the server', async () => { + stubFetch(() => { + throw new TypeError('offline'); + }); + render(); + submit(); + expect(await screen.findByText(keys.users.common.error_try_again)).toBeInTheDocument(); + }); + + it('offers verification when the account is unverified', async () => { + stubFetch(() => json(400, { detail: 'LOGIN_USER_NOT_VERIFIED' })); + render(); + submit(); + await waitFor(() => + expect(screen.queryByText(keys.users.login.error_invalid_credentials)).toBeNull(), + ); + expect(document.querySelector('input[type="password"]')).toBeNull(); + }); +}); diff --git a/modules/users/tests-js/Register.test.tsx b/modules/users/tests-js/users_Register.test.tsx similarity index 100% rename from modules/users/tests-js/Register.test.tsx rename to modules/users/tests-js/users_Register.test.tsx diff --git a/modules/users/tests-js/users_actions.test.tsx b/modules/users/tests-js/users_actions.test.tsx new file mode 100644 index 000000000..dae25f7fe --- /dev/null +++ b/modules/users/tests-js/users_actions.test.tsx @@ -0,0 +1,88 @@ +import { configureI18n, keys } from '@simple-module-py/i18n'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const visit = vi.hoisted(() => vi.fn()); +const reload = vi.hoisted(() => vi.fn()); +vi.mock('@inertiajs/react', () => ({ + Head: () => null, + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), + router: { visit, reload }, + usePage: () => ({ props: {} }), +})); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { toast } from 'sonner'; +import ForgotPassword from '../users/pages/ForgotPassword'; +import AddPeople from '../users/pages/Users/AddPeople'; +import { json, sent, stubFetch } from './users_fixtures'; + +configureI18n({ locale: 'en', messages: {} }); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('users ForgotPassword', () => { + it('posts the address as JSON and shows the confirmation', async () => { + const mock = stubFetch(() => json(202, null)); + render(); + fireEvent.change(document.getElementById('email') as HTMLElement, { + target: { value: 'a@example.com' }, + }); + fireEvent.submit(document.querySelector('form') as HTMLFormElement); + await waitFor(() => expect(document.getElementById('email')).toBeNull()); + const call = await sent(mock); + expect(call.path).toBe('/api/users/auth/forgot-password'); + expect(JSON.parse(call.text)).toEqual({ email: 'a@example.com' }); + }); +}); + +describe('users AddPeople', () => { + const props = { roles: [], mailer_delivers: true, mailer_name: 'smtp', invite_expiry_days: 7 }; + + function create() { + window.history.pushState({}, '', '/admin/users/add?mode=create'); + render(); + fireEvent.change(document.querySelector('input[type="email"]') as HTMLElement, { + target: { value: 'new@example.com' }, + }); + fireEvent.change(document.querySelector('input[type="password"]') as HTMLElement, { + target: { value: 'Str0ng-Passphrase' }, + }); + fireEvent.submit(document.querySelector('form') as HTMLFormElement); + } + + it('creates a user with the documented body and returns to the list', async () => { + const mock = stubFetch(() => json(201, {})); + create(); + await waitFor(() => expect(visit).toHaveBeenCalledWith('/admin/users/')); + const call = await sent(mock); + expect(call.path).toBe('/api/users/admin'); + expect(JSON.parse(call.text)).toEqual({ + email: 'new@example.com', + password: 'Str0ng-Passphrase', + full_name: null, + role_names: [], + }); + expect(toast.success).toHaveBeenCalled(); + }); + + it('shows the server detail when creation is refused', async () => { + stubFetch(() => json(400, { detail: 'REGISTER_USER_ALREADY_EXISTS' })); + create(); + expect(await screen.findByText('REGISTER_USER_ALREADY_EXISTS')).toBeInTheDocument(); + expect(visit).not.toHaveBeenCalled(); + }); + + it('asks to try again when the request never reaches the server', async () => { + stubFetch(() => { + throw new TypeError('offline'); + }); + create(); + expect(await screen.findByText(keys.users.common.error_try_again)).toBeInTheDocument(); + }); +}); diff --git a/modules/users/tests-js/users_fixtures.ts b/modules/users/tests-js/users_fixtures.ts new file mode 100644 index 000000000..1bda48081 --- /dev/null +++ b/modules/users/tests-js/users_fixtures.ts @@ -0,0 +1,23 @@ +import { vi } from 'vitest'; + +/** Stub `fetch`; `respond` gets each Request the typed client (or `apiFetch`) sends. */ +export function stubFetch(respond: (request: Request) => Response | Promise) { + const mock = vi.fn(async (input: Request) => respond(input)); + vi.stubGlobal('fetch', mock); + return mock; +} + +export const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** Path, method, content type and raw body of the Request sent on call `index`. */ +export async function sent(mock: ReturnType, index = 0) { + const request = mock.mock.calls[index][0]; + return { + path: new URL(request.url).pathname, + method: request.method, + contentType: request.headers.get('Content-Type'), + credentials: request.credentials, + text: await request.clone().text(), + }; +} diff --git a/modules/users/users/admin/components/useUserRowActions.ts b/modules/users/users/admin/components/useUserRowActions.ts index be83a70d5..073f25d59 100644 --- a/modules/users/users/admin/components/useUserRowActions.ts +++ b/modules/users/users/admin/components/useUserRowActions.ts @@ -1,7 +1,10 @@ import { router } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../pages/api'; +import { failureMessage } from '../../pages/failureMessage'; /** * The per-row actions on the users table — resend, copy a reset link, and @@ -15,6 +18,7 @@ import { toast } from 'sonner'; */ export function useUserRowActions() { const { t } = useT(); + const network = t(keys.users.common.error_occurred); const [busy, setBusy] = useState(null); async function copyToClipboard(text: string): Promise { @@ -31,15 +35,19 @@ export function useUserRowActions() { async function resendInvite(userId: string, email: string) { setBusy(userId); try { - const resp = await fetch(`/api/users/admin/${userId}/resend-invite`, { method: 'POST' }); + const resp = await apiResult( + api.POST('/api/users/admin/{user_id}/resend-invite', { + params: { path: { user_id: userId } }, + }), + ); if (!resp.ok) { - toast.error(t(keys.users.user_row.toast_resend_failed)); + toast.error(failureMessage(resp, t(keys.users.user_row.toast_resend_failed), network)); return; } - const body = await resp.json(); + const body = resp.data; // A mailer that only logs hands back the link instead: putting it on the // clipboard is the difference between "invited" and "actually reachable". - if (body.status === 'link' && body.link) { + if (body?.status === 'link' && body.link) { const copied = await copyToClipboard(body.link); toast.success( copied @@ -60,15 +68,16 @@ export function useUserRowActions() { async function copyResetLink(userId: string) { setBusy(userId); try { - const resp = await fetch(`/api/users/admin/${userId}/reset-password-link`, { - method: 'POST', - }); + const resp = await apiResult( + api.POST('/api/users/admin/{user_id}/reset-password-link', { + params: { path: { user_id: userId } }, + }), + ); if (!resp.ok) { - toast.error(t(keys.users.user_row.toast_reset_failed)); + toast.error(failureMessage(resp, t(keys.users.user_row.toast_reset_failed), network)); return; } - const body = await resp.json(); - const copied = await copyToClipboard(body.link ?? ''); + const copied = await copyToClipboard(resp.data?.link ?? ''); if (copied) toast.success(t(keys.users.user_row.toast_reset_copied)); else toast.error(t(keys.users.user_row.toast_reset_failed)); } catch { @@ -81,11 +90,14 @@ export function useUserRowActions() { async function setActive(userId: string, active: boolean) { setBusy(userId); try { - const resp = await fetch(`/api/users/admin/${userId}/${active ? 'enable' : 'disable'}`, { - method: 'PATCH', - }); + const params = { params: { path: { user_id: userId } } }; + const resp = await apiResult( + active + ? api.PATCH('/api/users/admin/{user_id}/enable', params) + : api.PATCH('/api/users/admin/{user_id}/disable', params), + ); if (!resp.ok) { - toast.error(t(keys.users.user_row.toast_status_failed)); + toast.error(failureMessage(resp, t(keys.users.user_row.toast_status_failed), network)); return; } toast.success( diff --git a/modules/users/users/auth_local/components/PasswordCard.tsx b/modules/users/users/auth_local/components/PasswordCard.tsx index e1a336511..1207659b1 100644 --- a/modules/users/users/auth_local/components/PasswordCard.tsx +++ b/modules/users/users/auth_local/components/PasswordCard.tsx @@ -5,8 +5,11 @@ import { SectionTitle } from '@simple-module-py/ui/components/SectionTitle'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; import { Label } from '@simple-module-py/ui/components/ui/label'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { useId, useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../pages/api'; +import { failureMessage } from '../../pages/failureMessage'; /** * Change your own password. @@ -40,17 +43,19 @@ export function PasswordCard() { } setSaving(true); try { - const resp = await fetch('/api/users/me/password', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ current_password: current, new_password: next }), - }); + const resp = await apiResult( + api.POST('/api/users/me/password', { + body: { current_password: current, new_password: next }, + }), + ); if (!resp.ok) { - const body = await resp.json().catch(() => ({})); setError( - typeof body?.detail === 'string' - ? body.detail - : t(keys.users.profile.toast_password_failed), + failureMessage( + resp, + t(keys.users.profile.toast_password_failed), + t(keys.users.common.error_occurred), + true, + ), ); return; } diff --git a/modules/users/users/auth_local/components/ProfileDetailsCard.tsx b/modules/users/users/auth_local/components/ProfileDetailsCard.tsx index 57ce79271..dfa1a6ec0 100644 --- a/modules/users/users/auth_local/components/ProfileDetailsCard.tsx +++ b/modules/users/users/auth_local/components/ProfileDetailsCard.tsx @@ -4,9 +4,12 @@ import { Button } from '@simple-module-py/ui/components/ui/button'; import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; import { Input } from '@simple-module-py/ui/components/ui/input'; import { Label } from '@simple-module-py/ui/components/ui/label'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { initials } from '@simple-module-py/ui/lib/initials'; import { useId, useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../pages/api'; +import { failureMessage } from '../../pages/failureMessage'; import type { ProfileUser } from './profile-user'; /** @@ -31,22 +34,21 @@ export function ProfileDetailsCard({ user }: { user: ProfileUser }) { const submit = (event: React.FormEvent) => { event.preventDefault(); setSaving(true); - fetch('/api/users/me', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ full_name: fullName }), - }) - .then(async (res) => { + apiResult(api.PATCH('/api/users/me', { body: { full_name: fullName } })) + .then((res) => { if (res.ok) { toast.success(t(keys.users.profile.toast_updated)); return; } - const data = await res.json().catch(() => ({})); toast.error( - typeof data?.detail === 'string' ? data.detail : t(keys.users.profile.toast_failed), + failureMessage( + res, + t(keys.users.profile.toast_failed), + t(keys.users.common.error_occurred), + true, + ), ); }) - .catch(() => toast.error(t(keys.users.common.error_occurred))) .finally(() => setSaving(false)); }; diff --git a/modules/users/users/auth_local/components/ResendVerification.tsx b/modules/users/users/auth_local/components/ResendVerification.tsx index 8fd9f5f57..5ded72b68 100644 --- a/modules/users/users/auth_local/components/ResendVerification.tsx +++ b/modules/users/users/auth_local/components/ResendVerification.tsx @@ -1,7 +1,9 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Input } from '@simple-module-py/ui/components/ui/input'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { useState } from 'react'; +import { api } from '../../pages/api'; interface ResendVerificationProps { /** Decoded from the dead token. Null when it could not be read at all. */ @@ -35,13 +37,8 @@ export function ResendVerification({ email }: ResendVerificationProps) { const resend = () => { setFailed(false); setSending(true); - fetch('/api/users/auth/request-verify-token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: address }), - }) + apiResult(api.POST('/api/users/auth/request-verify-token', { body: { email: address } })) .then((res) => (res.ok ? setSent(true) : setFailed(true))) - .catch(() => setFailed(true)) .finally(() => setSending(false)); }; diff --git a/modules/users/users/auth_local/components/SessionsCard.tsx b/modules/users/users/auth_local/components/SessionsCard.tsx index 6789e87e4..f4b8cbf67 100644 --- a/modules/users/users/auth_local/components/SessionsCard.tsx +++ b/modules/users/users/auth_local/components/SessionsCard.tsx @@ -3,10 +3,13 @@ import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActi import { SectionTitle } from '@simple-module-py/ui/components/SectionTitle'; import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; import { useRelativeTime } from '@simple-module-py/ui/hooks/use-relative-time'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { describeUserAgent } from '@simple-module-py/ui/lib/user-agent'; import { LogOut } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../pages/api'; +import { failureMessage } from '../../pages/failureMessage'; interface Props { lastLoginAt: string | null; @@ -35,22 +38,22 @@ export function SessionsCard({ lastLoginAt }: Props) { async function revokeAll() { setBusy(true); - try { - const resp = await fetch('/api/users/me/sessions/revoke-all', { method: 'POST' }); - if (!resp.ok) { - toast.error(t(keys.users.profile.toast_signed_out_failed)); - setBusy(false); - setConfirming(false); - return; - } - // This browser is signed out too, so a full page load is the point — - // an Inertia visit would carry a session the server has just refused. - window.location.assign('/users/login'); - } catch { - toast.error(t(keys.users.common.error_occurred)); + const resp = await apiResult(api.POST('/api/users/me/sessions/revoke-all')); + if (!resp.ok) { + toast.error( + failureMessage( + resp, + t(keys.users.profile.toast_signed_out_failed), + t(keys.users.common.error_occurred), + ), + ); setBusy(false); setConfirming(false); + return; } + // This browser is signed out too, so a full page load is the point — + // an Inertia visit would carry a session the server has just refused. + window.location.assign('/users/login'); } return ( diff --git a/modules/users/users/pages/AcceptInvite.tsx b/modules/users/users/pages/AcceptInvite.tsx index 87648f455..367669b19 100644 --- a/modules/users/users/pages/AcceptInvite.tsx +++ b/modules/users/users/pages/AcceptInvite.tsx @@ -3,6 +3,7 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Input } from '@simple-module-py/ui/components/ui/input'; import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; +import { apiResult, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes'; import { BRAND_DEFAULT_APP_NAME } from '@simple-module-py/ui/lib/brand'; import { useHomeUrl } from '@simple-module-py/ui/lib/home-url'; @@ -13,6 +14,7 @@ import { AuthIntro } from '../auth_local/components/AuthIntro'; import { AuthStateCard } from '../auth_local/components/AuthStateCard'; import { InviteSummary } from '../auth_local/components/InviteSummary'; import { PasswordFields } from '../auth_local/components/PasswordFields'; +import { api } from './api'; interface InvitePreview { email: string; @@ -51,25 +53,22 @@ function AcceptInvite({ token, invite, branding }: Props) { } setMismatch(false); setLoading(true); - fetch('/api/users/auth/accept-invite', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, password, full_name: fullName }), - }) - .then(async (res) => { - if (res.status === 204 || res.status === 200) { + apiResult( + api.POST('/api/users/auth/accept-invite', { + body: { token, password, full_name: fullName }, + }), + ) + .then((res) => { + if (res.status === NETWORK_FAILURE) { + setError(t(keys.users.common.error_try_again)); + } else if (res.ok) { router.visit(homeUrl); + } else if (res.detail === 'INVITE_BAD_TOKEN') { + setError(t(keys.users.accept_invite.error_bad_token)); } else { - const data = await res.json().catch(() => ({})); - const detail = typeof data?.detail === 'string' ? data.detail : ''; - if (detail === 'INVITE_BAD_TOKEN') { - setError(t(keys.users.accept_invite.error_bad_token)); - } else { - setError(detail || t(keys.users.accept_invite.error_failed)); - } + setError(res.detail || t(keys.users.accept_invite.error_failed)); } }) - .catch(() => setError(t(keys.users.common.error_try_again))) .finally(() => setLoading(false)); }; diff --git a/modules/users/users/pages/ForgotPassword.tsx b/modules/users/users/pages/ForgotPassword.tsx index 0f413cbad..89aee8b1f 100644 --- a/modules/users/users/pages/ForgotPassword.tsx +++ b/modules/users/users/pages/ForgotPassword.tsx @@ -3,10 +3,12 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Input } from '@simple-module-py/ui/components/ui/input'; import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes'; import { useState } from 'react'; import { AuthField } from '../auth_local/components/AuthField'; import { ResetLinkSent } from '../auth_local/components/ResetLinkSent'; +import { api } from './api'; interface Props { /** How long the emailed link stays usable — straight from settings. */ @@ -23,13 +25,9 @@ function ForgotPassword({ reset_link_lifetime_minutes, mailer_delivers }: Props) /** Resolves to whether the server took the request — never rejects. */ const requestLink = (): Promise => - fetch('/api/users/auth/forgot-password', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }) - .then((res) => res.ok) - .catch(() => false); + apiResult(api.POST('/api/users/auth/forgot-password', { body: { email } })).then( + (res) => res.ok, + ); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); diff --git a/modules/users/users/pages/Login.tsx b/modules/users/users/pages/Login.tsx index 51f39dbe1..b08277616 100644 --- a/modules/users/users/pages/Login.tsx +++ b/modules/users/users/pages/Login.tsx @@ -3,10 +3,13 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; import { AuthSplitAside } from '@simple-module-py/ui/layouts/AuthSplitAside'; +import { apiResult, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; import { useState } from 'react'; import { type DemoAccount, DemoSignIn } from '../auth_local/components/DemoSignIn'; import { LoginForm, type OAuthProvider } from '../auth_local/components/LoginForm'; import { WaitingOnYou } from '../auth_local/components/WaitingOnYou'; +import { api } from './api'; +import { signIn } from './signIn'; interface DevAccount { label: string; @@ -61,32 +64,20 @@ function Login({ setError(null); setNeedsVerification(false); setLoading(true); - const body = new URLSearchParams({ - username, - password: pwd, - remember: String(remember), - }); - fetch('/api/users/auth/login', { - method: 'POST', - body, - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) - .then(async (res) => { - if (res.status === 204) { + signIn(username, pwd, remember) + .then((res) => { + if (res.status === NETWORK_FAILURE) { + setError(t(keys.users.common.error_try_again)); + } else if (res.status === 204) { router.visit(nextUrl); } else if (res.status === 429) { setError(t(keys.users.login.error_rate_limited)); + } else if (res.detail === 'LOGIN_USER_NOT_VERIFIED') { + setNeedsVerification(true); } else { - const data = await res.json().catch(() => ({})); - const detail = typeof data?.detail === 'string' ? data.detail : ''; - if (detail === 'LOGIN_USER_NOT_VERIFIED') { - setNeedsVerification(true); - } else { - setError(t(keys.users.login.error_invalid_credentials)); - } + setError(t(keys.users.login.error_invalid_credentials)); } }) - .catch(() => setError(t(keys.users.common.error_try_again))) .finally(() => setLoading(false)); }; @@ -97,22 +88,23 @@ function Login({ setDemoError(null); setError(null); setDemoPendingRole(role); - fetch(`/api/users/auth/demo/${role}`, { method: 'POST' }) - .then((res) => { + apiResult(api.POST('/api/users/auth/demo/{role}', { params: { path: { role } } })).then( + (res) => { if (res.status === 204) { router.visit(nextUrl); return; } - // 404 is that account having been switched off since this page was - // rendered; 429 is its throughput budget. Neither is worth its own - // copy — both mean "not right now". - setDemoError(t(keys.users.login.demo_error)); - setDemoPendingRole(null); - }) - .catch(() => { - setDemoError(t(keys.users.common.error_try_again)); + if (res.status === NETWORK_FAILURE) { + setDemoError(t(keys.users.common.error_try_again)); + } else { + // 404 is that account having been switched off since this page was + // rendered; 429 is its throughput budget. Neither is worth its own + // copy — both mean "not right now". + setDemoError(t(keys.users.login.demo_error)); + } setDemoPendingRole(null); - }); + }, + ); }; const handleSubmit = (e: React.FormEvent) => { @@ -125,13 +117,9 @@ function Login({ // the auth throughput limiter, and a 429 announced as "sent" leaves // someone waiting for an email that was never queued. setResendFailed(false); - fetch('/api/users/auth/request-verify-token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }) - .then((res) => (res.ok ? setResent(true) : setResendFailed(true))) - .catch(() => setResendFailed(true)); + apiResult(api.POST('/api/users/auth/request-verify-token', { body: { email } })).then((res) => + res.ok ? setResent(true) : setResendFailed(true), + ); }; const aside = ( diff --git a/modules/users/users/pages/Register.tsx b/modules/users/users/pages/Register.tsx index e067263da..d1f544a1a 100644 --- a/modules/users/users/pages/Register.tsx +++ b/modules/users/users/pages/Register.tsx @@ -3,6 +3,7 @@ import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Input } from '@simple-module-py/ui/components/ui/input'; import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; +import { apiFetch, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes'; import { MailCheck, UserCheck } from 'lucide-react'; import { useState } from 'react'; @@ -91,19 +92,21 @@ function Register({ require_verification = true }: RegisterProps) { } setMismatch(false); setLoading(true); - fetch('/api/users/auth/register', { + // Untyped on purpose: the generated `UserCreate` demands the privileged + // `is_active`/`is_superuser`/`is_verified` keys, which a sign-up must not send. + apiFetch('/api/users/auth/register', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, full_name: fullName }), + json: { email, password, full_name: fullName }, }) - .then(async (res) => { + .then((res) => { if (res.status === 201) { setSuccess(true); + } else if (res.status === NETWORK_FAILURE) { + setError(FAILED); } else { - setError(mapRegisterError(await res.json().catch(() => ({})))); + setError(mapRegisterError(res.error ?? {})); } }) - .catch(() => setError(FAILED)) .finally(() => setLoading(false)); }; diff --git a/modules/users/users/pages/ResetPassword.tsx b/modules/users/users/pages/ResetPassword.tsx index 73bd30321..0b82447d0 100644 --- a/modules/users/users/pages/ResetPassword.tsx +++ b/modules/users/users/pages/ResetPassword.tsx @@ -2,12 +2,15 @@ import { Head, router } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; +import { apiResult, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes'; import { useHomeUrl } from '@simple-module-py/ui/lib/home-url'; import { CheckCircle2, TimerOff } from 'lucide-react'; import { useState } from 'react'; import { AuthStateCard } from '../auth_local/components/AuthStateCard'; import { PasswordFields } from '../auth_local/components/PasswordFields'; +import { api } from './api'; +import { signIn } from './signIn'; const FORGOT_PATH = '/users/forgot-password'; @@ -40,18 +43,9 @@ function ResetPassword({ * * Never rejects: by the time this runs the password has already been * changed, and a network error here must not be reported as a failed reset. */ - const signIn = async () => { + const signedIn = async () => { if (!email) return false; - try { - const res = await fetch('/api/users/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ username: email, password }), - }); - return res.status === 204; - } catch { - return false; - } + return (await signIn(email, password)).status === 204; }; const handleSubmit = (e: React.FormEvent) => { @@ -63,22 +57,17 @@ function ResetPassword({ } setMismatch(false); setLoading(true); - fetch('/api/users/auth/reset-password', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, password }), - }) + apiResult(api.POST('/api/users/auth/reset-password', { body: { token, password } })) .then(async (res) => { + if (res.status === NETWORK_FAILURE) { + setError(t(keys.users.common.error_try_again)); + return; + } if (res.status !== 200 && res.status !== 204) { - const data = await res.json().catch(() => ({})); - setError( - typeof data?.detail === 'string' - ? data.detail - : t(keys.users.reset_password.error_failed), - ); + setError(res.detail ?? t(keys.users.reset_password.error_failed)); return; } - if (await signIn()) { + if (await signedIn()) { router.visit(homeUrl); return; } @@ -88,7 +77,6 @@ function ResetPassword({ // them the sign-in page. setSaved(true); }) - .catch(() => setError(t(keys.users.common.error_try_again))) .finally(() => setLoading(false)); }; diff --git a/modules/users/users/pages/Users/AddPeople.tsx b/modules/users/users/pages/Users/AddPeople.tsx index 72702a404..da2716920 100644 --- a/modules/users/users/pages/Users/AddPeople.tsx +++ b/modules/users/users/pages/Users/AddPeople.tsx @@ -1,22 +1,16 @@ -import { Head, Link, router } from '@inertiajs/react'; +import { Head, Link } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; -import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner'; import { PageShell } from '@simple-module-py/ui/components/PageShell'; import { SegmentedControl } from '@simple-module-py/ui/components/SegmentedControl'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout'; -import { TriangleAlert } from 'lucide-react'; -import type React from 'react'; -import { useState } from 'react'; -import { toast } from 'sonner'; import { CreateUserFields } from './components/CreateUserFields'; import { InviteFields } from './components/InviteFields'; -import { type InviteResult, InviteResults } from './components/InviteResults'; +import { InviteResults } from './components/InviteResults'; +import { MailerBanner } from './components/MailerBanner'; import { type Role, RolePicker } from './components/RolePicker'; -import { isPlausibleEmail } from './invite-emails'; - -type Mode = 'invite' | 'create'; +import { useAddPeople } from './components/useAddPeople'; interface Props { roles: Role[]; @@ -28,13 +22,6 @@ interface Props { } const USERS_INDEX = '/admin/users/'; -const SETTINGS_URL = '/admin/settings/'; -const BULK_INVITE_URL = '/api/users/admin/invite/bulk'; - -function initialMode(): Mode { - if (typeof window === 'undefined') return 'invite'; - return new URLSearchParams(window.location.search).get('mode') === 'create' ? 'create' : 'invite'; -} /** * One screen for both ways of adding people. @@ -56,127 +43,7 @@ function AddPeople({ invite_expiry_days: expiryDays, }: Props) { const { t } = useT(); - - const [mode, setMode] = useState(initialMode); - const [selectedRoles, setSelectedRoles] = useState([]); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(false); - const [results, setResults] = useState([]); - const [retrying, setRetrying] = useState(null); - - // Invite mode - const [emails, setEmails] = useState([]); - const [message, setMessage] = useState(''); - // Create mode - const [email, setEmail] = useState(''); - const [fullName, setFullName] = useState(''); - const [password, setPassword] = useState(''); - - const toggleRole = (roleName: string) => - setSelectedRoles((prev) => - prev.includes(roleName) ? prev.filter((r) => r !== roleName) : [...prev, roleName], - ); - - // Invalid chips are excluded rather than blocking: one typo in a pasted - // column must not hold up the other nineteen addresses. - const validEmails = emails.filter(isPlausibleEmail); - - async function postInvites(addresses: string[]): Promise { - const resp = await fetch(BULK_INVITE_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - emails: addresses, - role_names: selectedRoles, - message: message.trim() || null, - }), - }); - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - setError( - typeof body?.detail === 'string' - ? body.detail - : t(keys.users.add_people.error_invite_failed), - ); - return null; - } - return (await resp.json()).results ?? []; - } - - async function submitInvite() { - const next = await postInvites(validEmails); - if (next === null) return; - setResults(next); - const sent = next.filter((r) => r.status === 'sent').length; - if (sent > 0) toast.success(t(keys.users.add_people.toast_invites_sent, { count: sent })); - // Only clear the box when nothing needs following up — otherwise the - // admin loses the list they still have to act on. - if (next.every((r) => r.status !== 'failed')) { - setEmails([]); - setMessage(''); - } - } - - /** Re-post one address, replacing just its row in the last batch. */ - async function retry(address: string) { - setRetrying(address); - setError(null); - try { - const next = await postInvites([address]); - if (next === null || next.length === 0) return; - setResults((prev) => prev.map((row) => (row.email === address ? next[0] : row))); - } catch { - setError(t(keys.users.common.error_try_again)); - } finally { - setRetrying(null); - } - } - - async function submitCreate() { - const resp = await fetch('/api/users/admin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email, - password, - full_name: fullName || null, - role_names: selectedRoles, - }), - }); - if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - setError( - typeof body?.detail === 'string' - ? body.detail - : t(keys.users.add_people.error_create_failed), - ); - return; - } - toast.success(t(keys.users.add_people.toast_user_created)); - router.visit(USERS_INDEX); - } - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setLoading(true); - try { - await (mode === 'invite' ? submitInvite() : submitCreate()); - } catch { - setError(t(keys.users.common.error_try_again)); - } finally { - setLoading(false); - } - } - - const canSubmit = - mode === 'invite' ? validEmails.length > 0 : email.trim() !== '' && password !== ''; - - const submitLabel = loading - ? t(keys.users.add_people.submitting) - : mode === 'invite' - ? t(keys.users.add_people.submit_invite, { count: validEmails.length }) - : t(keys.users.add_people.submit_create); + const s = useAddPeople(); return ( <> @@ -187,11 +54,8 @@ function AddPeople({ back={USERS_INDEX} > { - setMode(next); - setError(null); - }} + value={s.mode} + onChange={s.changeMode} aria-label={t(keys.users.add_people.tablist_label)} options={[ { value: 'invite', label: t(keys.users.add_people.mode_invite) }, @@ -200,68 +64,43 @@ function AddPeople({ className="mb-4" /> - {!mailerDelivers && ( - // One sentence, as the deck has it — a title/body pair reads as two - // separate facts when it is really one: which mailer, and what that - // means for the links. The "Configure SMTP" link ends the sentence - // rather than sitting right-aligned away from what it refers to. - - {t(keys.users.add_people.mailer_banner_prefix)} {mailerName}{' '} - {t(keys.users.add_people.mailer_banner_suffix)}{' '} - {/* The 44px tap area is grown with a pseudo-element: an inline - link inside a sentence cannot take `min-h-11` without - stretching the line box around it. */} - - {t(keys.users.add_people.configure_smtp)} - - - } - /> - )} + {!mailerDelivers && } {/* "Last batch" belongs to the invite flow; creating one account with a password you set has no batch to report. */}
-
- {mode === 'invite' ? ( + + {s.mode === 'invite' ? ( ) : ( <> - + )} - {error &&

{error}

} + {s.error &&

{s.error}

}
- {mode === 'invite' && ( + {s.mode === 'invite' && ( )}
diff --git a/modules/users/users/pages/Users/components/DangerZone.tsx b/modules/users/users/pages/Users/components/DangerZone.tsx index 71ad4b0d0..b457e7d1c 100644 --- a/modules/users/users/pages/Users/components/DangerZone.tsx +++ b/modules/users/users/pages/Users/components/DangerZone.tsx @@ -3,9 +3,12 @@ import { keys, useT } from '@simple-module-py/i18n'; import { ConfirmActionDialog } from '@simple-module-py/ui/components/ConfirmActionDialog'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { Trash2, TriangleAlert } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../api'; +import { failureMessage } from '../../failureMessage'; interface Props { userId: string; @@ -31,25 +34,25 @@ export function DangerZone({ userId, email, isSelf }: Props) { const handleDelete = () => { setDeleting(true); - fetch(`/api/users/admin/${userId}`, { method: 'DELETE' }) - .then(async (res) => { - if (res.ok) { - toast.success(t(keys.users.danger_zone.toast_deleted)); - router.visit('/admin/users/'); - return; // navigating away — leave `deleting` set - } - const data = await res.json().catch(() => ({})); - toast.error( - typeof data?.detail === 'string' ? data.detail : t(keys.users.danger_zone.toast_failed), - ); - setDeleting(false); - setOpen(false); - }) - .catch(() => { - toast.error(t(keys.users.common.error_occurred)); - setDeleting(false); - setOpen(false); - }); + apiResult( + api.DELETE('/api/users/admin/{user_id}', { params: { path: { user_id: userId } } }), + ).then((res) => { + if (res.ok) { + toast.success(t(keys.users.danger_zone.toast_deleted)); + router.visit('/admin/users/'); + return; // navigating away — leave `deleting` set + } + toast.error( + failureMessage( + res, + t(keys.users.danger_zone.toast_failed), + t(keys.users.common.error_occurred), + true, + ), + ); + setDeleting(false); + setOpen(false); + }); }; return ( diff --git a/modules/users/users/pages/Users/components/MailerBanner.tsx b/modules/users/users/pages/Users/components/MailerBanner.tsx new file mode 100644 index 000000000..3d7ce4b63 --- /dev/null +++ b/modules/users/users/pages/Users/components/MailerBanner.tsx @@ -0,0 +1,37 @@ +import { Link } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { InlineBanner } from '@simple-module-py/ui/components/InlineBanner'; +import { TriangleAlert } from 'lucide-react'; + +const SETTINGS_URL = '/admin/settings/'; + +/** Warns that the configured mailer cannot deliver, so invite links must be shared by hand. */ +export function MailerBanner({ mailerName }: { mailerName: string }) { + const { t } = useT(); + return ( + // One sentence, as the deck has it — a title/body pair reads as two + // separate facts when it is really one: which mailer, and what that + // means for the links. The "Configure SMTP" link ends the sentence + // rather than sitting right-aligned away from what it refers to. + + {t(keys.users.add_people.mailer_banner_prefix)} {mailerName}{' '} + {t(keys.users.add_people.mailer_banner_suffix)}{' '} + {/* The 44px tap area is grown with a pseudo-element: an inline + link inside a sentence cannot take `min-h-11` without + stretching the line box around it. */} + + {t(keys.users.add_people.configure_smtp)} + + + } + /> + ); +} diff --git a/modules/users/users/pages/Users/components/useAddPeople.ts b/modules/users/users/pages/Users/components/useAddPeople.ts new file mode 100644 index 000000000..167824038 --- /dev/null +++ b/modules/users/users/pages/Users/components/useAddPeople.ts @@ -0,0 +1,157 @@ +import { router } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { apiResult, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; +import type React from 'react'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { api } from '../../api'; +import { isPlausibleEmail } from '../invite-emails'; +import type { InviteResult } from './InviteResults'; + +export type Mode = 'invite' | 'create'; + +const USERS_INDEX = '/admin/users/'; + +function initialMode(): Mode { + if (typeof window === 'undefined') return 'invite'; + return new URLSearchParams(window.location.search).get('mode') === 'create' ? 'create' : 'invite'; +} + +/** State and submit flow for both modes of the Add people screen. */ +export function useAddPeople() { + const { t } = useT(); + + const [mode, setMode] = useState(initialMode); + const [selectedRoles, setSelectedRoles] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState([]); + const [retrying, setRetrying] = useState(null); + + // Invite mode + const [emails, setEmails] = useState([]); + const [message, setMessage] = useState(''); + // Create mode + const [email, setEmail] = useState(''); + const [fullName, setFullName] = useState(''); + const [password, setPassword] = useState(''); + + const toggleRole = (roleName: string) => + setSelectedRoles((prev) => + prev.includes(roleName) ? prev.filter((r) => r !== roleName) : [...prev, roleName], + ); + + // Invalid chips are excluded rather than blocking: one typo in a pasted + // column must not hold up the other nineteen addresses. + const validEmails = emails.filter(isPlausibleEmail); + + /** A failed result as the form's error line; the network case reads "try again". */ + const failWith = (res: { status: number; detail: string | null }, fallback: string) => + setError( + res.status === NETWORK_FAILURE + ? t(keys.users.common.error_try_again) + : (res.detail ?? fallback), + ); + + async function postInvites(addresses: string[]): Promise { + const res = await apiResult( + api.POST('/api/users/admin/invite/bulk', { + body: { emails: addresses, role_names: selectedRoles, message: message.trim() || null }, + }), + ); + if (!res.ok) { + failWith(res, t(keys.users.add_people.error_invite_failed)); + return null; + } + return (res.data?.results as InviteResult[] | undefined) ?? []; + } + + async function submitInvite() { + const next = await postInvites(validEmails); + if (next === null) return; + setResults(next); + const sent = next.filter((r) => r.status === 'sent').length; + if (sent > 0) toast.success(t(keys.users.add_people.toast_invites_sent, { count: sent })); + // Only clear the box when nothing needs following up — otherwise the + // admin loses the list they still have to act on. + if (next.every((r) => r.status !== 'failed')) { + setEmails([]); + setMessage(''); + } + } + + /** Re-post one address, replacing just its row in the last batch. */ + async function retry(address: string) { + setRetrying(address); + setError(null); + try { + const next = await postInvites([address]); + if (next === null || next.length === 0) return; + setResults((prev) => prev.map((row) => (row.email === address ? next[0] : row))); + } finally { + setRetrying(null); + } + } + + async function submitCreate() { + const res = await apiResult( + api.POST('/api/users/admin', { + body: { email, password, full_name: fullName || null, role_names: selectedRoles }, + }), + ); + if (!res.ok) { + failWith(res, t(keys.users.add_people.error_create_failed)); + return; + } + toast.success(t(keys.users.add_people.toast_user_created)); + router.visit(USERS_INDEX); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await (mode === 'invite' ? submitInvite() : submitCreate()); + } finally { + setLoading(false); + } + } + + const canSubmit = + mode === 'invite' ? validEmails.length > 0 : email.trim() !== '' && password !== ''; + + const submitLabel = loading + ? t(keys.users.add_people.submitting) + : mode === 'invite' + ? t(keys.users.add_people.submit_invite, { count: validEmails.length }) + : t(keys.users.add_people.submit_create); + + return { + mode, + changeMode: (next: Mode) => { + setMode(next); + setError(null); + }, + selectedRoles, + toggleRole, + error, + loading, + results, + retrying, + retry, + emails, + setEmails, + message, + setMessage, + email, + setEmail, + fullName, + setFullName, + password, + setPassword, + canSubmit, + submitLabel, + handleSubmit, + }; +} diff --git a/modules/users/users/pages/Users/components/useEditUserForm.ts b/modules/users/users/pages/Users/components/useEditUserForm.ts index 55086dee3..a95349553 100644 --- a/modules/users/users/pages/Users/components/useEditUserForm.ts +++ b/modules/users/users/pages/Users/components/useEditUserForm.ts @@ -1,7 +1,9 @@ import { router } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; +import { apiResult, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; import { useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../api'; export interface EditableUser { id: string; @@ -108,27 +110,28 @@ export function useEditUserForm(user: EditableUser) { // "unsaved" — it is already persisted, and saying otherwise invites the // admin to redo work or distrust the indicator entirely. if (detailsDirty) { - const resp = await fetch(`/api/users/admin/${user.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: form.email, full_name: form.fullName || null }), - }); + const resp = await apiResult( + api.PATCH('/api/users/admin/{user_id}', { + params: { path: { user_id: user.id } }, + body: { email: form.email, full_name: form.fullName || null }, + }), + ); if (!resp.ok) { - const body = await resp.json().catch(() => ({})); - setError( - typeof body?.detail === 'string' ? body.detail : t(keys.users.edit.error_details), - ); + if (resp.status === NETWORK_FAILURE) throw new Error('network'); + setError(resp.detail ?? t(keys.users.edit.error_details)); return; } setBaseline((prev) => ({ ...prev, email: form.email, fullName: form.fullName })); } if (rolesDirty) { - const resp = await fetch(`/api/users/admin/${user.id}/roles`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role_names: form.roles }), - }); + const resp = await apiResult( + api.PUT('/api/users/admin/{user_id}/roles', { + params: { path: { user_id: user.id } }, + body: { role_names: form.roles }, + }), + ); if (!resp.ok) { + if (resp.status === NETWORK_FAILURE) throw new Error('network'); setError( detailsDirty ? t(keys.users.edit.error_partial) : t(keys.users.edit.error_roles), ); diff --git a/modules/users/users/pages/Users/components/useUserActions.ts b/modules/users/users/pages/Users/components/useUserActions.ts index 8c31cbf2d..466066031 100644 --- a/modules/users/users/pages/Users/components/useUserActions.ts +++ b/modules/users/users/pages/Users/components/useUserActions.ts @@ -1,6 +1,9 @@ import { keys, useT } from '@simple-module-py/i18n'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { useState } from 'react'; import { toast } from 'sonner'; +import { api } from '../../api'; +import { failureMessage } from '../../failureMessage'; /** * The immediate account actions on the edit page — disable/enable, mark @@ -23,24 +26,30 @@ export function useUserActions( const [savingStatus, setSavingStatus] = useState(false); const [savingVerify, setSavingVerify] = useState(false); - const patch = (url: string, onSuccess: () => void, okMsg: string, failMsg: string) => { + const patch = ( + call: Promise<{ response: Response; data?: unknown; error?: unknown }>, + onSuccess: () => void, + okMsg: string, + failMsg: string, + ) => { setSavingStatus(true); - fetch(url, { method: 'PATCH' }) + apiResult(call) .then((res) => { if (res.ok) { onSuccess(); toast.success(okMsg); } else { - toast.error(failMsg); + toast.error(failureMessage(res, failMsg, t(keys.users.common.error_occurred))); } }) - .catch(() => toast.error(t(keys.users.common.error_occurred))) .finally(() => setSavingStatus(false)); }; + const params = { params: { path: { user_id: userId } } }; + const disable = () => patch( - `/api/users/admin/${userId}/disable`, + api.PATCH('/api/users/admin/{user_id}/disable', params), () => setIsActive(false), t(keys.users.edit.toast_disabled), t(keys.users.edit.toast_disable_failed), @@ -48,7 +57,7 @@ export function useUserActions( const enable = () => patch( - `/api/users/admin/${userId}/enable`, + api.PATCH('/api/users/admin/{user_id}/enable', params), () => setIsActive(true), t(keys.users.edit.toast_enabled), t(keys.users.edit.toast_enable_failed), @@ -56,7 +65,7 @@ export function useUserActions( const unlock = () => patch( - `/api/users/admin/${userId}/unlock`, + api.PATCH('/api/users/admin/{user_id}/unlock', params), () => setLockedUntil(null), t(keys.users.edit.toast_unlocked), t(keys.users.edit.toast_unlock_failed), @@ -64,28 +73,38 @@ export function useUserActions( const markVerified = () => { setSavingVerify(true); - fetch(`/api/users/admin/${userId}/verify`, { method: 'PATCH' }) + apiResult(api.PATCH('/api/users/admin/{user_id}/verify', params)) .then((res) => { if (res.ok) { setIsVerified(true); toast.success(t(keys.users.edit.toast_verified)); } else { - toast.error(t(keys.users.edit.toast_verify_failed)); + toast.error( + failureMessage( + res, + t(keys.users.edit.toast_verify_failed), + t(keys.users.common.error_occurred), + ), + ); } }) - .catch(() => toast.error(t(keys.users.common.error_occurred))) .finally(() => setSavingVerify(false)); }; const copyResetLink = () => { - fetch(`/api/users/admin/${userId}/reset-password-link`, { method: 'POST' }) + apiResult(api.POST('/api/users/admin/{user_id}/reset-password-link', params)) .then(async (res) => { if (res.ok) { - const data = await res.json(); - await navigator.clipboard.writeText(data.link ?? data.url ?? ''); + await navigator.clipboard.writeText(res.data?.link ?? ''); toast.success(t(keys.users.edit.toast_reset_copied)); } else { - toast.error(t(keys.users.edit.toast_reset_failed)); + toast.error( + failureMessage( + res, + t(keys.users.edit.toast_reset_failed), + t(keys.users.common.error_occurred), + ), + ); } }) .catch(() => toast.error(t(keys.users.common.error_occurred))); diff --git a/modules/users/users/pages/VerifyEmail.tsx b/modules/users/users/pages/VerifyEmail.tsx index f49b5fd98..631286011 100644 --- a/modules/users/users/pages/VerifyEmail.tsx +++ b/modules/users/users/pages/VerifyEmail.tsx @@ -2,11 +2,13 @@ import { Head } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; import { Button } from '@simple-module-py/ui/components/ui/button'; import { AuthCardShell } from '@simple-module-py/ui/layouts/AuthCardShell'; +import { apiResult } from '@simple-module-py/ui/lib/api'; import { LOGIN_PATH } from '@simple-module-py/ui/lib/auth-routes'; import { CheckCircle2, Loader2, TimerOff, Unlink } from 'lucide-react'; import { useEffect, useState } from 'react'; import { AuthStateCard, type AuthStateTone } from '../auth_local/components/AuthStateCard'; import { ResendVerification } from '../auth_local/components/ResendVerification'; +import { api } from './api'; interface Props { token: string; @@ -31,21 +33,13 @@ function VerifyEmail({ token, email, verification_lifetime_days: lifetimeDays }: return; } - fetch('/api/users/auth/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }) - .then(async (res) => { - if (res.status === 200 || res.status === 204) { - setStatus('success'); - return; - } - const data = await res.json().catch(() => ({})); - const detail = typeof data?.detail === 'string' ? data.detail : ''; - setStatus(detail === 'VERIFY_USER_ALREADY_VERIFIED' ? 'already_verified' : 'expired'); - }) - .catch(() => setStatus('expired')); + apiResult(api.POST('/api/users/auth/verify', { body: { token } })).then((res) => { + if (res.status === 200 || res.status === 204) { + setStatus('success'); + return; + } + setStatus(res.detail === 'VERIFY_USER_ALREADY_VERIFIED' ? 'already_verified' : 'expired'); + }); }, [token]); const signInButton = ( diff --git a/modules/users/users/pages/api.generated.ts b/modules/users/users/pages/api.generated.ts new file mode 100644 index 000000000..87702e1ab --- /dev/null +++ b/modules/users/users/pages/api.generated.ts @@ -0,0 +1,1921 @@ +// AUTO-GENERATED by `smpy openapi gen-types` from the Users module's /api +// operations — do not edit. Regenerate with `smpy openapi gen-types --module users` +// (`make gen-api-types` in the framework repo). +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/users/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Login + * @description Lockout-guarded login wrapper. Sets sm_auth cookie + session user_id. + */ + post: operations["users_login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Api Logout + * @description API logout — clears both the access-token cookie and the session. + */ + post: operations["users_api_logout"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/accept-invite": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Accept Invite + * @description Verify an invite token, set the user's password, and log them in. + */ + post: operations["users_accept_invite"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read Me + * @description Return the currently authenticated user's profile. + */ + get: operations["users_read_me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update Me + * @description Update the currently authenticated user's profile. + */ + patch: operations["users_update_me"]; + trace?: never; + }; + "/api/users/me/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Change My Password + * @description Change your own password, proving you know the current one. + * + * A live session is not proof enough: an unattended browser is exactly what + * this guards against, and re-asking is the only thing that distinguishes + * the account's owner from whoever sat down next. + * + * 400 rather than 403 for an SSO account: there is no password here to be + * forbidden from changing, so this is a bad request about a field that does + * not apply, not an authorisation decision. + * + * Succeeding also revokes every *other* session, and every bearer credential. + * Changing a password is what someone does when they think an old session is + * in the wrong hands, and a change that leaves those signed in does not do the + * one thing it was reached for. The browser doing the changing keeps working: + * its session is re-stamped with the new value rather than being caught by its + * own bump. A bearer client is not re-stamped and must sign in again — there + * is nowhere to write the new counter to a caller holding only a token. + */ + post: operations["users_change_my_password"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/me/sessions/revoke-all": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Revoke All My Sessions + * @description Sign this account out of every browser and client, including this one. + * + * Browser auth is a signed cookie rather than a row, so there is nothing to + * delete for a laptop left on a train. Bumping ``session_version`` strands + * every session stamped with the old value wherever it is; the access-token + * rows and the refresh tokens are the bearer half of the same idea and go + * with it. The caller is signed out too — anything else would leave the one + * session that ordered the revocation as the exception to it. + */ + post: operations["users_revoke_all_my_sessions"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/demo/{role}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Demo Login + * @description Sign the caller in as the demo account for ``role``. + * + * 404 rather than 403 for every failure here — demo mode off, this account + * not configured, an unknown role — matching ``/users/register``: a disabled + * feature should look absent, not forbidden, so probing says nothing about + * how the instance is configured. + */ + post: operations["users_demo_login"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Token Login + * @description Exchange email + password for an access/refresh token pair. + */ + post: operations["users_token_login"]; + /** + * Token Revoke + * @description Revoke a refresh token (idempotent). + */ + delete: operations["users_token_revoke"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/token/refresh": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Token Refresh + * @description Rotate a refresh token into a new access/refresh pair. + */ + post: operations["users_token_refresh"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/admin/invite/bulk": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Admin Bulk Invite + * @description Invite every address in *data*, all sharing the same roles. + */ + post: operations["users_admin_bulk_invite"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/admin/{user_id}/resend-invite": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Admin Resend Invite + * @description Re-issue the invitation for a pending account. + * + * 202 rather than 200: the account was already updated, but whether the mail + * reaches anyone is the mailer's business and not something this response can + * promise. The body reports which of the two actually happened, reusing the + * bulk form's per-address shape so the admin screens read one result type. + */ + post: operations["users_admin_resend_invite"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/admin": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Admin List Users + * @description List all users (paginated, optional search and filters). + */ + get: operations["users_admin_list_users"]; + put?: never; + /** + * Admin Create User + * @description Create an active+verified user with an admin-set password. + */ + post: operations["users_admin_create_user"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/admin/invite": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Admin Invite User + * @description Invite a new user by email, optionally assigning roles. + */ + post: operations["users_admin_invite_user"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/admin/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Admin Delete User + * @description Hard-delete a user. An admin cannot delete their own account. + */ + delete: operations["users_admin_delete_user"]; + options?: never; + head?: never; + /** + * Admin Update User + * @description Update a user's email and full name. + */ + patch: operations["users_admin_update_user"]; + trace?: never; + }; + "/api/users/admin/{user_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Admin Disable User + * @description Disable a user account (sets is_active=False and disabled_at). + */ + patch: operations["users_admin_disable_user"]; + trace?: never; + }; + "/api/users/admin/{user_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Admin Enable User + * @description Re-enable a previously disabled user account. + */ + patch: operations["users_admin_enable_user"]; + trace?: never; + }; + "/api/users/admin/{user_id}/unlock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Admin Unlock User + * @description Lift a login lockout (GH #530) before its cooldown ends. + */ + patch: operations["users_admin_unlock_user"]; + trace?: never; + }; + "/api/users/admin/{user_id}/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Admin Set Roles + * @description Replace a user's role assignments. + */ + put: operations["users_admin_set_roles"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/admin/{user_id}/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Admin Mark Verified + * @description Mark a user verified. Idempotent. + */ + patch: operations["users_admin_mark_verified"]; + trace?: never; + }; + "/api/users/admin/{user_id}/reset-password-link": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Admin Reset Password Link + * @description Generate a password-reset link for the given user (admin copy). + */ + post: operations["users_admin_reset_password_link"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/forgot-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reset:Forgot Password */ + post: operations["users_reset:forgot_password"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/reset-password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reset:Reset Password */ + post: operations["users_reset:reset_password"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/request-verify-token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verify:Request-Token */ + post: operations["users_verify:request-token"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Verify:Verify */ + post: operations["users_verify:verify"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register:Register */ + post: operations["users_register:register"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/{provider}/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Begin + * @description Generate a state nonce, stash it in the session, redirect to the IdP. + */ + get: operations["users_begin"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/auth/{provider}/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Users Oauth Callback + * @description Verify state, exchange code, find-or-create user, set cookie, redirect. + */ + get: operations["users_users_oauth_callback"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** AcceptInviteRequest */ + AcceptInviteRequest: { + /** Token */ + token: string; + /** Password */ + password: string; + /** Full Name */ + full_name?: string | null; + }; + /** Body_users_reset:forgot_password */ + Body_users_reset_forgot_password: { + /** + * Email + * Format: email + */ + email: string; + }; + /** Body_users_reset:reset_password */ + Body_users_reset_reset_password: { + /** Token */ + token: string; + /** Password */ + password: string; + }; + /** Body_users_verify:request-token */ + "Body_users_verify_request-token": { + /** + * Email + * Format: email + */ + email: string; + }; + /** Body_users_verify:verify */ + Body_users_verify_verify: { + /** Token */ + token: string; + }; + /** BulkInviteResponse */ + BulkInviteResponse: { + /** Results */ + results: components["schemas"]["BulkInviteResult"][]; + }; + /** + * BulkInviteResult + * @description Outcome for a single address in a bulk invite. + * + * Per-address rather than all-or-nothing: one already-registered address in + * a pasted list of twenty should not discard the other nineteen. + */ + BulkInviteResult: { + /** Email */ + email: string; + /** Status */ + status: string; + /** + * Detail + * @default + */ + detail: string; + /** Link */ + link?: string | null; + }; + /** ErrorModel */ + ErrorModel: { + /** Detail */ + detail: string | { + [key: string]: string; + }; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * LoginRequest + * @description The sign-in form, posted url-encoded. + * + * Field names match ``OAuth2PasswordRequestForm`` so existing clients keep + * working; ``grant_type``/``scope``/``client_id`` are still accepted on the + * wire and ignored. ``remember`` is the "Keep me signed in" checkbox: it + * lengthens the issued credential rather than changing what is checked. + */ + LoginRequest: { + /** Username */ + username: string; + /** Password */ + password: string; + /** + * Remember + * @default false + */ + remember: boolean; + }; + /** PasswordResetLink */ + PasswordResetLink: { + /** Link */ + link: string; + }; + /** + * RefreshRequest + * @description Body for refresh and revoke endpoints. + */ + RefreshRequest: { + /** Refresh Token */ + refresh_token: string; + }; + /** RoleAssignment */ + RoleAssignment: { + /** Role Names */ + role_names: string[]; + }; + /** + * SelfPasswordChange + * @description Change your own password from the profile page. + * + * The current password is required even though the caller already holds a + * session: an unattended browser is exactly the case this guards against, + * and it is the only proof that the person at the keyboard is the account's + * owner rather than whoever sat down next. + */ + SelfPasswordChange: { + /** Current Password */ + current_password: string; + /** New Password */ + new_password: string; + }; + /** SelfProfileUpdate */ + SelfProfileUpdate: { + /** Full Name */ + full_name?: string | null; + }; + /** + * TokenRequest + * @description Email + password login for bearer-token auth. + */ + TokenRequest: { + /** Email */ + email: string; + /** Password */ + password: string; + }; + /** + * TokenResponse + * @description Access + refresh token pair returned on successful auth. + */ + TokenResponse: { + /** Access Token */ + access_token: string; + /** Refresh Token */ + refresh_token: string; + /** + * Token Type + * @default Bearer + */ + token_type: string; + /** Expires In */ + expires_in: number; + }; + /** UserAdminCreate */ + UserAdminCreate: { + /** + * Email + * Format: email + */ + email: string; + /** Password */ + password: string; + /** Full Name */ + full_name?: string | null; + /** + * Role Names + * @default [] + */ + role_names: string[]; + }; + /** + * UserBulkInvite + * @description Invite several addresses in one submit, all sharing the same roles. + */ + UserBulkInvite: { + /** Emails */ + emails: string[]; + /** + * Role Names + * @default [] + */ + role_names: string[]; + /** Message */ + message?: string | null; + }; + /** UserCreate */ + UserCreate: { + /** + * Email + * Format: email + */ + email: string; + /** Password */ + password: string; + /** + * Is Active + * @default true + */ + is_active: boolean | null; + /** + * Is Superuser + * @default false + */ + is_superuser: boolean | null; + /** + * Is Verified + * @default false + */ + is_verified: boolean | null; + /** Full Name */ + full_name?: string | null; + }; + /** UserDetailsUpdate */ + UserDetailsUpdate: { + /** + * Email + * Format: email + */ + email: string; + /** Full Name */ + full_name?: string | null; + }; + /** UserInvite */ + UserInvite: { + /** + * Email + * Format: email + */ + email: string; + /** Full Name */ + full_name?: string | null; + /** + * Role Names + * @default [] + */ + role_names: string[]; + }; + /** UserListItem */ + UserListItem: { + /** + * Id + * Format: uuid + */ + id: string; + /** Email */ + email: string; + /** Full Name */ + full_name?: string | null; + /** Is Active */ + is_active: boolean; + /** Is Verified */ + is_verified: boolean; + /** + * Is External + * @default false + */ + is_external: boolean; + /** Disabled At */ + disabled_at?: string | null; + /** Last Login At */ + last_login_at?: string | null; + /** Created At */ + created_at?: string | null; + /** Invited At */ + invited_at?: string | null; + /** Invite Expires At */ + invite_expires_at?: string | null; + /** + * State + * @default active + */ + state: string; + /** Locked Until */ + locked_until?: string | null; + /** + * Roles + * @default [] + */ + roles: string[]; + }; + /** UserRead */ + UserRead: { + /** + * Id + * Format: uuid + */ + id: string; + /** Email */ + email: string; + /** + * Is Active + * @default true + */ + is_active: boolean; + /** + * Is Superuser + * @default false + */ + is_superuser: boolean; + /** + * Is Verified + * @default false + */ + is_verified: boolean; + /** + * Is External + * @default false + */ + is_external: boolean; + /** Full Name */ + full_name?: string | null; + /** Disabled At */ + disabled_at?: string | null; + /** Last Login At */ + last_login_at?: string | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + /** Input */ + input?: unknown; + /** Context */ + ctx?: Record; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + users_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/x-www-form-urlencoded": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_api_logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + users_accept_invite: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AcceptInviteRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_read_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + }; + }; + users_update_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SelfProfileUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_change_my_password: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SelfPasswordChange"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_revoke_all_my_sessions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + users_demo_login: { + parameters: { + query?: never; + header?: never; + path: { + role: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_token_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TokenRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TokenResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_token_revoke: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RefreshRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_token_refresh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RefreshRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TokenResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_bulk_invite: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserBulkInvite"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkInviteResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_resend_invite: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BulkInviteResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_list_users: { + parameters: { + query?: { + q?: string | null; + status?: string | null; + role?: string | null; + verified?: string | null; + sort?: string; + order?: string; + page?: number; + per_page?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListItem"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_create_user: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserAdminCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_invite_user: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserInvite"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_delete_user: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_update_user: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserDetailsUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_disable_user: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_enable_user: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_unlock_user: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_set_roles: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RoleAssignment"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_mark_verified: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserListItem"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_admin_reset_password_link: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PasswordResetLink"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "users_reset:forgot_password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_users_reset_forgot_password"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "users_reset:reset_password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_users_reset_reset_password"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "users_verify:request-token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_users_verify_request-token"]; + }; + }; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "users_verify:verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_users_verify_verify"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + "users_register:register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserRead"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorModel"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_begin: { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + users_users_oauth_callback: { + parameters: { + query?: { + code?: string | null; + state?: string | null; + }; + header?: never; + path: { + provider: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/modules/users/users/pages/api.ts b/modules/users/users/pages/api.ts new file mode 100644 index 000000000..b11e6c91f --- /dev/null +++ b/modules/users/users/pages/api.ts @@ -0,0 +1,9 @@ +/** + * Typed client for this module's `/api` routes (#504). The `paths` types are + * generated from the routes — regenerate with `make gen-api-types` (or + * `smpy openapi gen-types --module users`), never edit them by hand. + */ +import { createApi } from '@simple-module-py/ui/lib/api'; +import type { paths } from './api.generated'; + +export const api = createApi(); diff --git a/modules/users/users/pages/failureMessage.ts b/modules/users/users/pages/failureMessage.ts new file mode 100644 index 000000000..b783d9eef --- /dev/null +++ b/modules/users/users/pages/failureMessage.ts @@ -0,0 +1,16 @@ +import { type ApiResult, NETWORK_FAILURE } from '@simple-module-py/ui/lib/api'; + +/** + * The text for a failed call: `network` when the request never reached the + * server, else the server's string `detail` when `useDetail` is set and it sent + * one, else `fallback`. + */ +export function failureMessage( + res: ApiResult, + fallback: string, + network: string, + useDetail = false, +): string { + if (res.status === NETWORK_FAILURE) return network; + return (useDetail ? res.detail : null) ?? fallback; +} diff --git a/modules/users/users/pages/signIn.ts b/modules/users/users/pages/signIn.ts new file mode 100644 index 000000000..66690f6f4 --- /dev/null +++ b/modules/users/users/pages/signIn.ts @@ -0,0 +1,25 @@ +import { type ApiResult, apiResult } from '@simple-module-py/ui/lib/api'; +import { api } from './api'; + +/** + * Password sign-in. The route takes a form-encoded body (the OAuth2 password + * form), so the typed client is given an explicit serializer; a 204 is success. + */ +export function signIn( + username: string, + password: string, + remember = false, +): Promise> { + return apiResult( + api.POST('/api/users/auth/login', { + body: { username, password, remember }, + bodySerializer: (b) => + new URLSearchParams({ + username: b.username, + password: b.password, + remember: String(b.remember), + }), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }), + ); +} From 81203e287d5b11c1513d1617946df7f0ddc202f2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 13:49:57 +0200 Subject: [PATCH 07/16] Fix tenant settings labels, signed-in 404 layout, invite email validation (#575) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 7 +++++ .../tests/test_error_page_shared_props.py | 16 ++++++++++ modules/branding/branding/locales/de.json | 9 ++++++ modules/branding/branding/locales/en.json | 9 ++++++ .../branding/branding/tenant_definitions.py | 1 + .../settings/settings/contracts/registry.py | 3 ++ modules/settings/settings/tenant_view.py | 3 ++ .../tenants/tenants/components/InviteForm.tsx | 11 ++++++- .../tenants/components/TenantSettingRow.tsx | 9 ++++-- modules/tenants/tenants/locales/de.json | 1 + modules/tenants/tenants/locales/en.json | 1 + .../tests-js/TenantSettingRow.test.tsx | 31 +++++++++++++++++++ .../tests-js/test_tenants_InviteForm.test.tsx | 10 ++++++ packages/i18n/src/generated-resources.ts | 8 +++++ packages/i18n/src/keys.generated.ts | 10 ++++++ packages/ui/src/components/ErrorPage.test.tsx | 28 +++++++++++++++++ packages/ui/src/components/ErrorShell.tsx | 15 +++++++++ 17 files changed, 169 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2099738a5..39d122c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### Fixed +- UX polish (#575): `/tenants/settings` shows translated labels instead of raw keys + (`SettingDefinition.label_key` / `TenantSettingView.label_key`, set by `branding`; en + de); + a signed-in 404 for an unknown URL keeps the app layout (sidebar, user menu); the tenant + invite form shows a translated inline error for an invalid email instead of the browser's + silent native validation. + ### Removed - Committed QA scratch directories `qa-shots/`, `.qa/` and `.verify/` (159 files); they are now gitignored (`dataset/` stays) (#511). - Unused `Combobox` shadcn component and the `@base-ui/react` dependency (nothing imported it; Radix has no combobox, use `cmdk` `Command` + `Popover`). **Behaviour change** for anyone importing `@simple-module-py/ui/components/ui/combobox` (#521). diff --git a/framework/hosting/tests/test_error_page_shared_props.py b/framework/hosting/tests/test_error_page_shared_props.py index 6df2bb71b..0dfb2d8d2 100644 --- a/framework/hosting/tests/test_error_page_shared_props.py +++ b/framework/hosting/tests/test_error_page_shared_props.py @@ -256,3 +256,19 @@ async def test_a_forbidden_page_url_is_relative_too( resp = await guarded_client.get(_GUARDED_PATH) assert resp.status_code == _FORBIDDEN assert _inertia_page(resp.text)["url"] == _GUARDED_PATH + + +class TestSignedInNotFoundCarriesLayoutProps: + """The client wraps a signed-in unknown-URL 404 in the app layout (#575). + + That needs ``auth.isAuthenticated`` and a non-empty menu in the page props; + pin that the host-level catch-all 404 delivers both. + """ + + async def test_unknown_url_has_signed_in_auth_and_a_menu( + self, authenticated_client: httpx.AsyncClient + ) -> None: + props = _inertia_page((await authenticated_client.get(_MISSING_PATH)).text)["props"] + assert props["auth"]["isAuthenticated"] is True + menus = props["menus"] + assert menus.get("sidebar") or menus.get("adminSidebar"), sorted(menus) diff --git a/modules/branding/branding/locales/de.json b/modules/branding/branding/locales/de.json index f18a70a48..a39f48f95 100644 --- a/modules/branding/branding/locales/de.json +++ b/modules/branding/branding/locales/de.json @@ -64,5 +64,14 @@ "logo_dark_file_id": "Logo für dunkle Oberflächen; verwendet ersatzweise das eigene Logo dieser Organisation.", "favicon_file_id": "Symbol für den Browser-Tab." }, + "tenant_setting_labels": { + "app_name": "Anwendungsname", + "primary_color": "Primärfarbe", + "design_pack": "Design-Paket", + "footer_text": "Fußzeilentext", + "logo_file_id": "Logo", + "logo_dark_file_id": "Logo (dunkle Flächen)", + "favicon_file_id": "Favicon" + }, "permission_group": "Branding" } diff --git a/modules/branding/branding/locales/en.json b/modules/branding/branding/locales/en.json index 06b2edba0..34db6dcb9 100644 --- a/modules/branding/branding/locales/en.json +++ b/modules/branding/branding/locales/en.json @@ -64,5 +64,14 @@ "logo_dark_file_id": "Logo for dark surfaces; falls back to this organisation's own logo.", "favicon_file_id": "Browser tab icon." }, + "tenant_setting_labels": { + "app_name": "Application name", + "primary_color": "Primary colour", + "design_pack": "Design pack", + "footer_text": "Footer text", + "logo_file_id": "Logo", + "logo_dark_file_id": "Logo (dark surfaces)", + "favicon_file_id": "Favicon" + }, "permission_group": "Branding" } diff --git a/modules/branding/branding/tenant_definitions.py b/modules/branding/branding/tenant_definitions.py index a6009c209..c6b850f0a 100644 --- a/modules/branding/branding/tenant_definitions.py +++ b/modules/branding/branding/tenant_definitions.py @@ -98,6 +98,7 @@ def register_tenant_definitions(app: FastAPI) -> None: default=str(defaults[name]), description=_DESCRIPTIONS[name], description_key=f"{PACKAGE}.tenant_settings.{name}", + label_key=f"{PACKAGE}.tenant_setting_labels.{name}", tenant_overridable=True, check=_make_check(name), upload_url=upload_url, diff --git a/modules/settings/settings/contracts/registry.py b/modules/settings/settings/contracts/registry.py index 3e64951b6..a876c4e6c 100644 --- a/modules/settings/settings/contracts/registry.py +++ b/modules/settings/settings/contracts/registry.py @@ -58,6 +58,8 @@ class SettingDefinition: of offering a text box for an opaque id. ``description_key`` is the i18n key the tenant settings page translates ``description`` with (the English ``description`` stays the fallback when the key is missing or unset). + ``label_key`` does the same for the row's title (the raw ``key`` is the + fallback). ``clear_via`` names the route that owns clearing the key (e.g. an upload route that also reaps the stored file): the generic DELETE routes answer 422 pointing there while the tenant exists. A platform operator may still @@ -76,6 +78,7 @@ class SettingDefinition: clear_via: str | Mapping[SettingScope, str] = "" upload_url: str = "" description_key: str = "" + label_key: str = "" def clear_route(definition: SettingDefinition, scope: SettingScope | str) -> str: diff --git a/modules/settings/settings/tenant_view.py b/modules/settings/settings/tenant_view.py index 22db08ca0..847a9d5fc 100644 --- a/modules/settings/settings/tenant_view.py +++ b/modules/settings/settings/tenant_view.py @@ -32,6 +32,8 @@ class TenantSettingView(SQLModel): """Set for a file-id key: upload here (POST) / clear here (DELETE).""" description_key: str = "" """i18n key for ``description``; the client falls back to ``description``.""" + label_key: str = "" + """i18n key for the row title; the client falls back to ``key``.""" def _shown(key: str, value: str | None, value_type: str) -> str | None: @@ -74,6 +76,7 @@ async def list_for_tenant( or "", upload_url=d.upload_url, description_key=d.description_key, + label_key=d.label_key, ) ) return views diff --git a/modules/tenants/tenants/components/InviteForm.tsx b/modules/tenants/tenants/components/InviteForm.tsx index fd72d31bf..ffcfdb6ca 100644 --- a/modules/tenants/tenants/components/InviteForm.tsx +++ b/modules/tenants/tenants/components/InviteForm.tsx @@ -16,6 +16,10 @@ interface Props { onInvited: () => void; } +// Same shape the browser's own `type=email` check enforces, so `noValidate` +// swaps the silent native bubble for our translated inline error. +const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const ROLE_KEY = keys.tenants.roles; /** @@ -38,6 +42,10 @@ export function InviteForm({ onInvited }: Props) { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + if (!EMAIL_SHAPE.test(email.trim())) { + setError(t(keys.tenants.members.invite_email_invalid)); + return; + } setSubmitting(true); setError(null); // A new attempt replaces the previous result; a failure must not leave the @@ -76,7 +84,7 @@ export function InviteForm({ onInvited }: Props) { {t(keys.tenants.members.invite_title)} -
+
setEmail(e.target.value)} placeholder={t(keys.tenants.members.invite_email_placeholder)} required + aria-invalid={error ? true : undefined} />
diff --git a/modules/tenants/tenants/components/TenantSettingRow.tsx b/modules/tenants/tenants/components/TenantSettingRow.tsx index c12512e0a..6a33228c2 100644 --- a/modules/tenants/tenants/components/TenantSettingRow.tsx +++ b/modules/tenants/tenants/components/TenantSettingRow.tsx @@ -21,6 +21,8 @@ export interface TenantSetting { /** i18n key for `description`, owned by the module that declared the key; * `description` (English) is the fallback when it is unset or missing. */ description_key?: string; + /** i18n key for the row title, same rule; the raw `key` is the fallback. */ + label_key?: string; } interface Props { @@ -42,6 +44,9 @@ export function TenantSettingRow({ setting, onChanged }: Props) { const description = setting.description_key ? t(setting.description_key as never, { defaultValue: setting.description }) : setting.description; + const label = setting.label_key + ? t(setting.label_key as never, { defaultValue: setting.key }) + : setting.key; const inputId = `tenant-setting-${setting.key}`; async function send(method: 'PUT' | 'DELETE') { @@ -65,8 +70,8 @@ export function TenantSettingRow({ setting, onChanged }: Props) { return (
-
diff --git a/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx b/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx index 24a3c6905..15a52a609 100644 --- a/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx +++ b/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom/vitest'; -import { configureI18n } from '@simple-module-py/i18n'; +import { configureI18n, keys } from '@simple-module-py/i18n'; import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, expect, test, vi } from 'vitest'; @@ -59,3 +59,25 @@ test('an invalid email shows a translated inline error and sends nothing (#575)' expect(screen.getByText('Enter a valid email address.')).toBeInTheDocument(); expect(fetchMock).not.toHaveBeenCalled(); }); + +test('a server error is not flagged on the email field; a client error clears on edit', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ detail: 'already_member' }), { status: 409 }), + ); + render(); + const input = screen.getByLabelText('Email'); + await userEvent.type(input, 'friend@example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Send invitation' })); + await screen.findByText(keys.tenants.errors.already_member); + expect(input).not.toHaveAttribute('aria-invalid'); + + await userEvent.clear(input); + await userEvent.type(input, 'notanemail'); + await userEvent.click(screen.getByRole('button', { name: 'Send invitation' })); + expect(screen.getByText('Enter a valid email address.')).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-invalid', 'true'); + + await userEvent.type(input, 'x'); + expect(screen.queryByText('Enter a valid email address.')).not.toBeInTheDocument(); + expect(input).not.toHaveAttribute('aria-invalid'); +}); diff --git a/modules/users/users/pages/Users/components/useEditUserForm.ts b/modules/users/users/pages/Users/components/useEditUserForm.ts index a95349553..0181341ca 100644 --- a/modules/users/users/pages/Users/components/useEditUserForm.ts +++ b/modules/users/users/pages/Users/components/useEditUserForm.ts @@ -117,8 +117,11 @@ export function useEditUserForm(user: EditableUser) { }), ); if (!resp.ok) { - if (resp.status === NETWORK_FAILURE) throw new Error('network'); - setError(resp.detail ?? t(keys.users.edit.error_details)); + setError( + resp.status === NETWORK_FAILURE + ? t(keys.users.common.error_occurred) + : (resp.detail ?? t(keys.users.edit.error_details)), + ); return; } setBaseline((prev) => ({ ...prev, email: form.email, fullName: form.fullName })); @@ -131,9 +134,12 @@ export function useEditUserForm(user: EditableUser) { }), ); if (!resp.ok) { - if (resp.status === NETWORK_FAILURE) throw new Error('network'); setError( - detailsDirty ? t(keys.users.edit.error_partial) : t(keys.users.edit.error_roles), + resp.status === NETWORK_FAILURE + ? t(keys.users.common.error_occurred) + : detailsDirty + ? t(keys.users.edit.error_partial) + : t(keys.users.edit.error_roles), ); return; } @@ -141,8 +147,6 @@ export function useEditUserForm(user: EditableUser) { } toast.success(t(keys.users.edit.toast_saved)); router.reload(); - } catch { - setError(t(keys.users.common.error_occurred)); } finally { setSaving(false); savingRef.current = false; diff --git a/packages/ui/src/components/ErrorPage.test.tsx b/packages/ui/src/components/ErrorPage.test.tsx index b27e0bd53..6b5565831 100644 --- a/packages/ui/src/components/ErrorPage.test.tsx +++ b/packages/ui/src/components/ErrorPage.test.tsx @@ -182,6 +182,16 @@ describe('an unknown URL outside /admin (#575)', () => { expect(screen.getByTestId('app-layout')).toBeInTheDocument(); }); + test('also wraps a 403 for a signed-in viewer', () => { + setPage('/reports', { + auth: { isAuthenticated: true }, + menus: { sidebar: [{ label: 'Home', url: '/' }] }, + }); + renderError({ status: 403, message: '' }); + + expect(screen.getByTestId('app-layout')).toBeInTheDocument(); + }); + test('stays bare for an anonymous visitor', () => { setPage('/does-not-exist', { auth: { isAuthenticated: false }, diff --git a/packages/ui/src/components/ErrorShell.tsx b/packages/ui/src/components/ErrorShell.tsx index 05fcff360..fec3d6811 100644 --- a/packages/ui/src/components/ErrorShell.tsx +++ b/packages/ui/src/components/ErrorShell.tsx @@ -27,9 +27,10 @@ export function ErrorShell({ children }: { children: ReactNode }) { Boolean(props.auth?.isAuthenticated) && Array.isArray(props.menus?.adminSidebar) && props.menus.adminSidebar.length > 0; - // Outside /admin, a signed-in viewer's unknown-URL 404 keeps the app layout - // too (#575). Needs a menu: without shared props there is nothing - // to render, and an anonymous visitor has no app to stay in. + // Outside /admin, a signed-in viewer's error page (404, 403, 5xx alike) keeps + // the app layout too (#575), consistent with the admin shell. Needs a menu: + // without shared props there is nothing to render, and an anonymous visitor + // has no app to stay in. const inAppShell = !isAdminPath(page.url) && Boolean(props.auth?.isAuthenticated) && From 682f3d3124fa8233da73e64a5122f6ab374a64cc Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 15:05:50 +0200 Subject: [PATCH 15/16] fix(users): keep /api access after a password change; admin reset link origin A self-service password change deleted every UserAccessToken row for the account, including the one behind the changing browser's sm_auth cookie, which fastapi_users.current_user reads on /api/users/me*. The Starlette session was re-stamped, so pages kept rendering while /api answered 401 (pre-existing on main, not a regression of the typed-client migration). The endpoint now reissues that cookie under the new session_version, keeping a remembered sign-in's window; other sessions and bearer tokens are still revoked. Password and sessions cards show failures inline. Admin-copied links (reset link, bulk-invite fallback) no longer use the users.base_url dev default: an explicit users.base_url wins, then HostSettings.public_base_url, then the request origin. Emails unchanged. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 10 +++ .../tests-js/users_profile_cards.test.tsx | 56 ++++++++++++++++ .../users/tests/test_bearer_token_expiry.py | 10 ++- .../test_users_password_change_keeps_api.py | 67 +++++++++++++++++++ .../tests/test_users_reset_link_origin.py | 48 +++++++++++++ modules/users/users/admin/api.py | 4 +- modules/users/users/admin/bulk_invite.py | 14 ++-- modules/users/users/admin/links.py | 38 +++++++++++ .../auth_local/components/PasswordCard.tsx | 6 +- .../auth_local/components/SessionsCard.tsx | 22 ++++-- .../users/users/auth_local/self_account.py | 40 ++++++++++- modules/users/users/pages/api.generated.ts | 4 +- 12 files changed, 294 insertions(+), 25 deletions(-) create mode 100644 modules/users/tests-js/users_profile_cards.test.tsx create mode 100644 modules/users/tests/test_users_password_change_keeps_api.py create mode 100644 modules/users/tests/test_users_reset_link_origin.py create mode 100644 modules/users/users/admin/links.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a2b2d47..3cc97e541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ All notable changes to this project are documented in this file. The format is b ### Fixed - Retained logs: a NUL character no longer drops the whole log batch on Postgres. +- Users: after a self-service password change the same browser kept its page session but + every `/api/users/me*` call (including "Sign out everywhere") answered 401, because the + change deleted the `sm_auth` access-token row that browser's cookie pointed at. + `POST /api/users/me/password` now re-issues that cookie under the new `session_version` + (keeping a remembered sign-in's window); other sessions and bearer tokens are still revoked. + The profile's password and sessions cards show the translated failure inline. +- Users: the admin "Copy reset link" (and the bulk-invite fallback link) no longer points at + `http://localhost:8000` on installs that never set `users.base_url`: an explicitly set + `users.base_url` wins, then `HostSettings.public_base_url`, then the origin the admin is + browsing. Emailed links are unchanged. - UX polish (#575): `/tenants/settings` shows translated labels instead of raw keys (`SettingDefinition.label_key` / `TenantSettingView.label_key`, set by `branding`; en + de); signed-in error pages outside `/admin` (e.g. a 404 for an unknown URL) keep the app layout (sidebar, user menu); the tenant diff --git a/modules/users/tests-js/users_profile_cards.test.tsx b/modules/users/tests-js/users_profile_cards.test.tsx new file mode 100644 index 000000000..204de2422 --- /dev/null +++ b/modules/users/tests-js/users_profile_cards.test.tsx @@ -0,0 +1,56 @@ +import '@testing-library/jest-dom/vitest'; +import { configureI18n } from '@simple-module-py/i18n'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { toast } from 'sonner'; +import { PasswordCard } from '../users/auth_local/components/PasswordCard'; +import { SessionsCard } from '../users/auth_local/components/SessionsCard'; +import { json, stubFetch } from './users_fixtures'; + +configureI18n({ + locale: 'en', + messages: { + 'users.profile.toast_signed_out_failed': 'Could not sign out the other sessions', + 'users.profile.toast_password_failed': 'Could not change your password', + 'users.profile.sign_out_everywhere': 'Sign out everywhere', + 'users.profile.sign_out_confirm_action': 'Sign out', + 'users.profile.change_password': 'Change password', + }, +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('SessionsCard', () => { + it('shows the translated failure on the card when sign-out everywhere is refused', async () => { + stubFetch(() => json(401, { detail: 'Unauthorized' })); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Sign out everywhere' })); + fireEvent.click(await screen.findByRole('button', { name: 'Sign out' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Could not sign out the other sessions', + ); + expect(toast.error).toHaveBeenCalledWith('Could not sign out the other sessions'); + }); +}); + +describe('PasswordCard', () => { + it('shows the translated failure when the change is refused without a detail', async () => { + stubFetch(() => json(401, {})); + render(); + const inputs = document.querySelectorAll('input'); + for (const input of inputs) { + fireEvent.change(input, { target: { value: 'BrandNewPass1!' } }); + } + fireEvent.submit(document.querySelector('form') as HTMLFormElement); + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent('Could not change your password'), + ); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/modules/users/tests/test_bearer_token_expiry.py b/modules/users/tests/test_bearer_token_expiry.py index 678798aa6..67a4fb4ba 100644 --- a/modules/users/tests/test_bearer_token_expiry.py +++ b/modules/users/tests/test_bearer_token_expiry.py @@ -192,16 +192,20 @@ async def test_a_version_bump_strands_a_token_on_both_paths(self, users_app, ano async def test_changing_the_password_deletes_the_rows(self, users_app, anon_client): """The stamped counter already strands them; deleting is what stops a stolen token resolving a row at all, and stops the refresh tokens - minting fresh ones behind it.""" + minting fresh ones behind it. The one row left is the changing + browser's reissued ``sm_auth`` credential, minted under the new counter.""" assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204 - assert await _tokens(users_app) + before = {row.token for row in await _tokens(users_app)} + assert before resp = await anon_client.post( "/api/users/me/password", json={"current_password": "AdminPass1!", "new_password": "NewAdminPass1!"}, ) assert resp.status_code == 204, resp.text - assert await _tokens(users_app) == [] + reissued = await _only_token(users_app) + assert reissued.token not in before + assert reissued.session_version == 1 class TestTheMintedCounterIsCurrent: diff --git a/modules/users/tests/test_users_password_change_keeps_api.py b/modules/users/tests/test_users_password_change_keeps_api.py new file mode 100644 index 000000000..bb52a5113 --- /dev/null +++ b/modules/users/tests/test_users_password_change_keeps_api.py @@ -0,0 +1,67 @@ +"""The browser that changes its password keeps working on ``/api/*`` too. + +A signed-in browser carries two credentials: the Starlette ``session`` cookie +the pages authenticate with, and the ``sm_auth`` access-token cookie behind +``fastapi_users.current_user`` on ``/api/users/me*``. The password change +re-stamped the first and deleted the row behind the second, so the profile page +kept rendering while every ``/api`` call it made answered 401 — including +"Sign out everywhere", which then failed silently. +""" + +from __future__ import annotations + +import httpx +import pytest + +_ADMIN = {"username": "admin@example.com", "password": "AdminPass1!"} +_CHANGE = {"current_password": "AdminPass1!", "new_password": "BrandNewPass1!"} + + +async def _login(client: httpx.AsyncClient) -> None: + resp = await client.post("/api/users/auth/login", data=_ADMIN) + assert resp.status_code == 204, resp.text + + +def _client(app) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") + + +@pytest.mark.anyio +async def test_changing_browser_keeps_api_access_and_others_are_revoked(anon_client, users_app): + other = _client(users_app) + async with other: + await _login(anon_client) + await _login(other) + assert (await other.get("/api/users/me")).status_code == 200 + + resp = await anon_client.post("/api/users/me/password", json=_CHANGE) + assert resp.status_code == 204, resp.text + + me = await anon_client.get("/api/users/me") + assert me.status_code == 200, me.text + page = await anon_client.get("/users/me", follow_redirects=False) + assert page.status_code == 200, page.text + + # The browser signed in before the change is stranded on both paths. + assert (await other.get("/api/users/me")).status_code == 401 + stale_page = await other.get("/users/me", follow_redirects=False) + assert stale_page.status_code in (302, 303, 401), stale_page.text + + revoke = await anon_client.post("/api/users/me/sessions/revoke-all") + assert revoke.status_code == 204, revoke.text + assert (await anon_client.get("/api/users/me")).status_code == 401 + + +@pytest.mark.anyio +async def test_a_remembered_sign_in_keeps_its_window(anon_client, users_app): + resp = await anon_client.post("/api/users/auth/login", data={**_ADMIN, "remember": "true"}) + assert resp.status_code == 204, resp.text + + resp = await anon_client.post("/api/users/me/password", json=_CHANGE) + assert resp.status_code == 204, resp.text + cookie_name = users_app.state.users.settings.cookie_name + set_cookie = next( + v for v in resp.headers.get_list("set-cookie") if v.startswith(f"{cookie_name}=") + ) + window = users_app.state.users.settings.remember_me_max_age_seconds + assert f"Max-Age={window}" in set_cookie diff --git a/modules/users/tests/test_users_reset_link_origin.py b/modules/users/tests/test_users_reset_link_origin.py new file mode 100644 index 000000000..1a4a6e498 --- /dev/null +++ b/modules/users/tests/test_users_reset_link_origin.py @@ -0,0 +1,48 @@ +"""The admin "Copy reset link" origin. + +It used ``users.base_url``, whose default is the dev origin, so every install +that had not set it copied links to ``http://localhost:8000``. +""" + +from __future__ import annotations + +import pytest +from _users_app_builders import _make_standard_user +from users.settings import UsersSettings + +_STOCK = UsersSettings.model_fields["base_url"].default + + +@pytest.fixture(autouse=True) +def _stock_users_base_url(users_app): + """The users fixture pins ``base_url`` to the test origin; restore the stock default.""" + users_app.state.users.settings.base_url = _STOCK + + +async def _link(admin_client, users_app) -> str: + user = await _make_standard_user(users_app) + resp = await admin_client.post(f"/api/users/admin/{user.id}/reset-password-link") + assert resp.status_code == 200, resp.text + return resp.json()["link"] + + +@pytest.mark.anyio +async def test_unconfigured_falls_back_to_the_serving_origin(admin_client, users_app): + assert _STOCK == "http://localhost:8000" + link = await _link(admin_client, users_app) + assert link.startswith("http://testserver/users/reset-password?token="), link + + +@pytest.mark.anyio +async def test_public_base_url_wins_over_the_request(admin_client, users_app): + users_app.state.host.settings.public_base_url = "https://app.example.org" + link = await _link(admin_client, users_app) + assert link.startswith("https://app.example.org/users/reset-password?token="), link + + +@pytest.mark.anyio +async def test_a_configured_users_base_url_matches_the_mailers(admin_client, users_app): + users_app.state.host.settings.public_base_url = "https://app.example.org" + users_app.state.users.settings.base_url = "https://accounts.example.org/" + link = await _link(admin_client, users_app) + assert link.startswith("https://accounts.example.org/users/reset-password?token="), link diff --git a/modules/users/users/admin/api.py b/modules/users/users/admin/api.py index 8c896aeb6..46ba85e94 100644 --- a/modules/users/users/admin/api.py +++ b/modules/users/users/admin/api.py @@ -13,6 +13,7 @@ from simple_module_hosting.permissions import RequiresPermission from users.admin.bulk_invite import bulk_router +from users.admin.links import admin_link_base from users.admin.resend_invite import resend_router from users.admin.service import UserService from users.constants import PERM_USERS_MANAGE, sanitize_list_filters @@ -263,9 +264,8 @@ async def admin_reset_password_link( service: UserService = Depends(get_user_service), ): """Generate a password-reset link for the given user (admin copy).""" - base_url = request.app.state.users.settings.base_url try: - link = await service.generate_reset_link(user_id, base_url) + link = await service.generate_reset_link(user_id, admin_link_base(request)) except UserNotFoundError: raise HTTPException(status_code=404, detail="User not found") from None except ExternalUserNoPasswordError: diff --git a/modules/users/users/admin/bulk_invite.py b/modules/users/users/admin/bulk_invite.py index e72c9942b..ac8bc913c 100644 --- a/modules/users/users/admin/bulk_invite.py +++ b/modules/users/users/admin/bulk_invite.py @@ -18,6 +18,7 @@ from simple_module_db.deps import get_db from sqlalchemy.ext.asyncio import AsyncSession +from users.admin.links import admin_link_base from users.admin.service import UserService from users.contracts.events import UserInvited from users.contracts.schemas import BulkInviteResponse, BulkInviteResult, UserBulkInvite @@ -69,17 +70,10 @@ def _failure_detail(exc: Exception) -> str: def invite_link(request: Request, token: str) -> str: """Build the accept URL the admin will hand to the invitee. - Uses the module's configured ``base_url`` — the same value both mailers - build their links from — rather than ``request.base_url``. Behind a reverse - proxy without ``SM_TRUSTED_PROXY``, the request's own base URL is the - internal origin (``http://10.0.0.5:8000``), and this link is *only* ever - surfaced when mail could not be delivered, i.e. exactly when the admin has - to pass it on by hand. Falls back to the request when the module's settings - are unavailable, so the link is never simply missing. + Surfaced only when mail could not be delivered, i.e. exactly when the admin + has to pass it on by hand. The origin follows :func:`admin_link_base`. """ - services = getattr(request.app.state, "users", None) - configured = getattr(getattr(services, "settings", None), "base_url", "") - base = str(configured or request.base_url).rstrip("/") + base = admin_link_base(request) return f"{base}/users/invite/accept?token={token}" diff --git a/modules/users/users/admin/links.py b/modules/users/users/admin/links.py new file mode 100644 index 000000000..087c68555 --- /dev/null +++ b/modules/users/users/admin/links.py @@ -0,0 +1,38 @@ +"""The origin an admin-copied link is built on. + +Links an admin copies in the browser (a password-reset link, an invite link +surfaced when mail could not be sent) used ``UsersSettings.base_url``, whose +default is the dev origin ``http://localhost:8000`` — so every install that had +not set it handed out links to localhost. + +Precedence, most deliberate first: + +1. ``users.base_url`` when an operator changed it — the origin the mailers use, + so a copied link matches an emailed one. +2. ``HostSettings.public_base_url`` — the site's declared public origin. +3. The request's own base URL. Safe *here* and only here: the link goes back to + the admin whose browser made this request, so its ``Host`` is the origin they + are already using. Emails never take this path — a ``Host`` header must not + pick where a link mailed to someone else points. +""" + +from __future__ import annotations + +from starlette.requests import Request + +from users.settings import UsersSettings + +_DEFAULT_BASE_URL = UsersSettings.model_fields["base_url"].default + + +def admin_link_base(request: Request) -> str: + """The origin (no trailing slash) to build an admin-copied link on.""" + users = getattr(getattr(request.app.state, "users", None), "settings", None) + configured = str(getattr(users, "base_url", "") or "") + if configured and configured != _DEFAULT_BASE_URL: + return configured.rstrip("/") + host = getattr(getattr(request.app.state, "host", None), "settings", None) + public = str(getattr(host, "public_base_url", "") or "") + if public: + return public.rstrip("/") + return str(request.base_url).rstrip("/") diff --git a/modules/users/users/auth_local/components/PasswordCard.tsx b/modules/users/users/auth_local/components/PasswordCard.tsx index 1207659b1..8761b4fc5 100644 --- a/modules/users/users/auth_local/components/PasswordCard.tsx +++ b/modules/users/users/auth_local/components/PasswordCard.tsx @@ -136,7 +136,11 @@ export function PasswordCard() { }} /> - {error &&

{error}

} + {error && ( +

+ {error} +

+ )}
+ {error && ( +

+ {error} +

+ )} Response: """Change your own password, proving you know the current one. @@ -62,7 +66,9 @@ async def change_my_password( in the wrong hands, and a change that leaves those signed in does not do the one thing it was reached for. The browser doing the changing keeps working: its session is re-stamped with the new value rather than being caught by its - own bump. A bearer client is not re-stamped and must sign in again — there + own bump, and its ``sm_auth`` cookie — the credential ``/api/users/me*`` + reads, whose row the revocation below deletes — is replaced by one minted + under the new counter. A bearer client is not re-stamped and must sign in again — there is nowhere to write the new counter to a caller holding only a token. """ if user.is_external or user.hashed_password is None: @@ -115,7 +121,37 @@ async def change_my_password( ) await db.execute(delete(UserAccessToken).where(UserAccessToken.user_id == user.id)) # ty: ignore[invalid-argument-type] await db.flush() - return Response(status_code=204) + return await _reissue_browser_cookie(request, user, access_token_db) + + +async def _reissue_browser_cookie(request: Request, user, access_token_db) -> Response: + """The 204 for a password change, carrying a fresh ``sm_auth`` cookie. + + The delete above takes this browser's own access-token row with it, and the + bump would strand it anyway, so re-stamping the session alone left the page + routes working and every ``/api`` route behind ``current_user`` answering + 401. Minted after the bump, the new row carries the new counter. The window + follows the sign-in it replaces: a remembered session keeps its thirty days. + A caller without the cookie (bearer) gets nothing — it has nowhere to keep it. + """ + settings = request.app.state.users.settings + if settings.cookie_name not in request.cookies: + return Response(status_code=204) + # Login records the remembered window as an int; ``True`` is the legacy shape. + remembered = request.session.get(SESSION_REMEMBER_KEY) + window = settings.cookie_max_age_seconds + if remembered is True: + window = settings.remember_me_max_age_seconds + elif not isinstance(remembered, bool) and isinstance(remembered, int) and remembered > 0: + window = remembered + transport = build_cookie_transport( + cookie_name=settings.cookie_name, + cookie_max_age_seconds=window, + cookie_secure=settings.cookie_secure, + cookie_samesite=settings.cookie_samesite, + ) + token = await build_strategy(access_token_db, window).write_token(user) + return await transport.get_login_response(token) @router.post("/me/sessions/revoke-all", status_code=204, dependencies=[Depends(Authenticated())]) diff --git a/modules/users/users/pages/api.generated.ts b/modules/users/users/pages/api.generated.ts index 87702e1ab..9b7410ca4 100644 --- a/modules/users/users/pages/api.generated.ts +++ b/modules/users/users/pages/api.generated.ts @@ -117,7 +117,9 @@ export interface paths { * in the wrong hands, and a change that leaves those signed in does not do the * one thing it was reached for. The browser doing the changing keeps working: * its session is re-stamped with the new value rather than being caught by its - * own bump. A bearer client is not re-stamped and must sign in again — there + * own bump, and its ``sm_auth`` cookie — the credential ``/api/users/me*`` + * reads, whose row the revocation below deletes — is replaced by one minted + * under the new counter. A bearer client is not re-stamped and must sign in again — there * is nowhere to write the new counter to a caller holding only a token. */ post: operations["users_change_my_password"]; From 23c53a96c7699e950bff0937b0c79a916bd793df Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 22 Oct 2026 15:11:00 +0200 Subject: [PATCH 16/16] fix(users): reissue sm_auth through the login helper, at today's remember window The password-change reissue duplicated the remembered-login cookie and strategy construction and minted for the window the session recorded at sign-in, so an operator who shortened remember_me_max_age_seconds still saw new credentials minted for the old, longer window. Both paths now go through users.backend.cookie_login_response, and the reissue mints for the current setting, as a fresh login would. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../test_users_password_change_keeps_api.py | 31 +++++++++++++++++++ modules/users/users/auth_local/api.py | 12 ++----- .../users/users/auth_local/self_account.py | 26 +++++++--------- modules/users/users/backend.py | 27 +++++++++++++++- 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/modules/users/tests/test_users_password_change_keeps_api.py b/modules/users/tests/test_users_password_change_keeps_api.py index bb52a5113..66109e5a4 100644 --- a/modules/users/tests/test_users_password_change_keeps_api.py +++ b/modules/users/tests/test_users_password_change_keeps_api.py @@ -65,3 +65,34 @@ async def test_a_remembered_sign_in_keeps_its_window(anon_client, users_app): ) window = users_app.state.users.settings.remember_me_max_age_seconds assert f"Max-Age={window}" in set_cookie + + +def _auth_cookie(resp: httpx.Response, cookie_name: str) -> str: + return next(v for v in resp.headers.get_list("set-cookie") if v.startswith(f"{cookie_name}=")) + + +@pytest.mark.anyio +async def test_a_plain_sign_in_gets_the_login_cookie_attributes(anon_client, users_app): + await _login(anon_client) + resp = await anon_client.post("/api/users/me/password", json=_CHANGE) + assert resp.status_code == 204, resp.text + settings = users_app.state.users.settings + set_cookie = _auth_cookie(resp, settings.cookie_name).lower() + assert f"max-age={settings.cookie_max_age_seconds}" in set_cookie + assert "httponly" in set_cookie + assert f"samesite={settings.cookie_samesite}" in set_cookie + assert "path=/" in set_cookie + + +@pytest.mark.anyio +async def test_a_shortened_remember_window_is_honoured(anon_client, users_app): + """The session records the window it was signed in for; the reissue mints + for today's setting, as a fresh sign-in would.""" + settings = users_app.state.users.settings + resp = await anon_client.post("/api/users/auth/login", data={**_ADMIN, "remember": "true"}) + assert resp.status_code == 204, resp.text + settings.remember_me_max_age_seconds = 3600 + + resp = await anon_client.post("/api/users/me/password", json=_CHANGE) + assert resp.status_code == 204, resp.text + assert "Max-Age=3600" in _auth_cookie(resp, settings.cookie_name) diff --git a/modules/users/users/auth_local/api.py b/modules/users/users/auth_local/api.py index 5d0abed78..9cd09e716 100644 --- a/modules/users/users/auth_local/api.py +++ b/modules/users/users/auth_local/api.py @@ -31,7 +31,7 @@ ) from users.auth_local.rate_limit import enforce_auth_throughput_limit from users.auth_local.self_account import router as self_account_router -from users.backend import build_cookie_transport, build_strategy +from users.backend import cookie_login_response from users.constants import SESSION_USER_ID_KEY from users.contracts.schemas import ( AcceptInviteRequest, @@ -84,15 +84,9 @@ async def _remembered_login_response(request: Request, user, access_token_db) -> than mutating singletons that concurrent requests are reading. """ settings = request.app.state.users.settings - window = settings.remember_me_max_age_seconds - strategy = build_strategy(access_token_db, window) - transport = build_cookie_transport( - cookie_name=settings.cookie_name, - cookie_max_age_seconds=window, - cookie_secure=settings.cookie_secure, - cookie_samesite=settings.cookie_samesite, + return await cookie_login_response( + access_token_db, user, settings, settings.remember_me_max_age_seconds ) - return await transport.get_login_response(await strategy.write_token(user)) @router.post("/auth/login", status_code=204) diff --git a/modules/users/users/auth_local/self_account.py b/modules/users/users/auth_local/self_account.py index e785318b6..da4597d13 100644 --- a/modules/users/users/auth_local/self_account.py +++ b/modules/users/users/auth_local/self_account.py @@ -22,7 +22,7 @@ from sqlalchemy import delete, update from users.auth_local.rate_limit import enforce_auth_throughput_limit -from users.backend import build_cookie_transport, build_strategy +from users.backend import cookie_login_response from users.constants import SESSION_VERSION_KEY from users.contracts.schemas import SelfPasswordChange, UserUpdate from users.db_adapter import get_access_token_db @@ -137,21 +137,19 @@ async def _reissue_browser_cookie(request: Request, user, access_token_db) -> Re settings = request.app.state.users.settings if settings.cookie_name not in request.cookies: return Response(status_code=204) - # Login records the remembered window as an int; ``True`` is the legacy shape. + # Login records the remembered window as an int; ``True`` is the legacy + # shape. Either way the reissue mints for the *current* remember-me + # setting, as a fresh login would, rather than the number the session + # recorded — an operator who shortened the window expects new credentials + # to honour it. remembered = request.session.get(SESSION_REMEMBER_KEY) - window = settings.cookie_max_age_seconds - if remembered is True: - window = settings.remember_me_max_age_seconds - elif not isinstance(remembered, bool) and isinstance(remembered, int) and remembered > 0: - window = remembered - transport = build_cookie_transport( - cookie_name=settings.cookie_name, - cookie_max_age_seconds=window, - cookie_secure=settings.cookie_secure, - cookie_samesite=settings.cookie_samesite, + is_remembered = remembered is True or ( + not isinstance(remembered, bool) and isinstance(remembered, int) and remembered > 0 ) - token = await build_strategy(access_token_db, window).write_token(user) - return await transport.get_login_response(token) + window = ( + settings.remember_me_max_age_seconds if is_remembered else settings.cookie_max_age_seconds + ) + return await cookie_login_response(access_token_db, user, settings, window) @router.post("/me/sessions/revoke-all", status_code=204, dependencies=[Depends(Authenticated())]) diff --git a/modules/users/users/backend.py b/modules/users/users/backend.py index 842f92a0c..2b91a60f0 100644 --- a/modules/users/users/backend.py +++ b/modules/users/users/backend.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Literal -from fastapi import Depends, Request +from fastapi import Depends, Request, Response from fastapi_users.authentication import AuthenticationBackend, CookieTransport from fastapi_users.authentication.strategy.db import AccessTokenDatabase @@ -82,6 +82,31 @@ def build_strategy( ) +async def cookie_login_response( + access_token_db: AccessTokenDatabase[UserAccessToken], + user, + settings: UsersSettings, + window_seconds: int, +) -> Response: + """Mint an access-token row for ``window_seconds`` and set it as the cookie. + + The one place a sign-in for a non-default window builds its credential, so + the row's deadline and the cookie's attributes cannot drift between the + login that set it and the password change that reissues it. The shared + backend's transport and strategy carry one lifetime each, so this builds a + second pair rather than mutating singletons concurrent requests are reading. + Note ``write_token`` commits the session it was given. + """ + transport = build_cookie_transport( + cookie_name=settings.cookie_name, + cookie_max_age_seconds=window_seconds, + cookie_secure=settings.cookie_secure, + cookie_samesite=settings.cookie_samesite, + ) + token = await build_strategy(access_token_db, window_seconds).write_token(user) + return await transport.get_login_response(token) + + def get_database_strategy( request: Request, access_token_db: AccessTokenDatabase[UserAccessToken] = Depends(get_access_token_db),