diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e1adb666d..f7f9ddbb0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -57,6 +57,8 @@ jobs: - run: make ci-check-test-sleeps # `as unknown as` switches the type checker off; use typed page args / usePageProps. - run: make ci-check-no-double-cast + # Requests go through the typed client (packages/ui/src/lib/api.ts), not raw fetch(). + - run: make ci-check-no-raw-fetch # The checked-in typed-key files must match what the installed modules' # catalogs emit. Catches a catalog edit committed without regenerating, and # (with the namespace guard in gen_i18n.py) a regeneration that dropped keys. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2099738a5..3cc97e541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### 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 + 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). @@ -159,6 +177,17 @@ All notable changes to this project are documented in this file. The format is b - tenants and api_tokens pages call their APIs through the typed client and take typed component props (#504). **Behaviour change:** the api_tokens pages no longer receive the unused `api_url` prop. +- users, background_tasks, branding, settings, file_storage and the first-run setup + wizard call their APIs through the typed client (#504), completing the migration. + **Behaviour change:** failed requests from these pages now show translated error + toasts (the server's `detail` or a localized fallback) instead of raw or generic + English text. `make lint` / CI reject raw `fetch(` in shipped `.ts`/`.tsx` + (`ci-check-no-raw-fetch`, `scripts/check_no_raw_fetch.py`) unless marked + `// raw-fetch-ok: `. +- file_storage's public file routes (`/api/file-storage/public/{file_id}` and + `.../{filename}`) now have distinct OpenAPI operation ids (`file_storage_public_file`, + `file_storage_public_file_named`); the cosmetic `filename` is a path segment on the + named route only. Paths and responses are unchanged. - Typed page props (#505): `@simple-module-py/ui` registers `SharedProps` (now including `i18n`) with Inertia, so `usePage().props.auth` is typed, and exports `usePageProps()` for components and layouts. Pages take typed component arguments. The `as unknown as` casts in module pages, `packages/ui` diff --git a/Makefile b/Makefile index c406c6ab2..3bfd53465 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-no-double-cast ci-check-hardcoded-strings ci-check-untranslated ci-check-metadata ci-check-locales ci-doc-paths ci-docs-build ci-i18n-fresh ci-api-types-fresh gen-api-types ci-css-build ci-doctor ci-build-packages worker beat worker-docker +.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-py-pg test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history migrations-roundtrip-pg docker-up docker-down kill new-module gen-pages openapi gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-test-sleeps ci-check-no-double-cast ci-check-no-raw-fetch ci-check-hardcoded-strings ci-check-untranslated ci-check-metadata ci-check-locales ci-doc-paths ci-docs-build ci-i18n-fresh ci-api-types-fresh gen-api-types ci-css-build ci-doctor ci-build-packages worker beat worker-docker # UV_SYNC_FLAGS is empty locally; CI passes --locked so a stale uv.lock fails the build. UV_SYNC_FLAGS ?= @@ -129,7 +129,7 @@ loadtest: ## Run locust against a server already on $(LOCUST_H loadtest-memray: ## Start uvicorn under memray, load-test, emit flamegraph scripts/loadtest_memray.sh $(LOCUST_ARGS) -lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-metadata ci-check-locales ci-doc-paths ci-i18n-fresh ci-api-types-fresh ci-check-file-size ci-check-test-sleeps ci-check-no-double-cast ci-check-hardcoded-strings ci-check-untranslated +lint: ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-metadata ci-check-locales ci-doc-paths ci-i18n-fresh ci-api-types-fresh ci-check-file-size ci-check-test-sleeps ci-check-no-double-cast ci-check-no-raw-fetch ci-check-hardcoded-strings ci-check-untranslated # Kept granular so pr.yml can run them in parallel. ci-python-lint: @@ -221,6 +221,9 @@ ci-check-test-sleeps: ci-check-no-double-cast: uv run python scripts/check_no_double_cast.py +ci-check-no-raw-fetch: + uv run python scripts/check_no_raw_fetch.py + # Enforce that permissions, role names, Inertia page ids, and module dependency # names are declared as named constants rather than hardcoded string literals. ci-check-hardcoded-strings: diff --git a/framework/hosting/simple_module_hosting/locales/de.json b/framework/hosting/simple_module_hosting/locales/de.json index 177b8a45e..5cf7d5dd3 100644 --- a/framework/hosting/simple_module_hosting/locales/de.json +++ b/framework/hosting/simple_module_hosting/locales/de.json @@ -18,6 +18,7 @@ "description": "Datenbankschema auf die von diesem Code erwartete Version aktualisieren." } }, + "request_failed": "Die Anfrage ist fehlgeschlagen. Bitte erneut versuchen.", "working": "In Bearbeitung…", "done": "Fertig. Wird neu geladen…" }, 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/locales/es.json b/framework/hosting/simple_module_hosting/locales/es.json index 73b095b57..37dd98c37 100644 --- a/framework/hosting/simple_module_hosting/locales/es.json +++ b/framework/hosting/simple_module_hosting/locales/es.json @@ -19,7 +19,8 @@ } }, "working": "Procesando…", - "done": "Hecho. Recargando…" + "done": "Hecho. Recargando…", + "request_failed": "La solicitud falló. Inténtalo de nuevo." }, "errors": { "token_not_allowed": "Los tokens de API no se pueden usar en esta ruta. Usa una sesión iniciada o una ruta protegida por un permiso incluido en el token." 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/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/background_tasks/background_tasks/log_sink.py b/modules/background_tasks/background_tasks/log_sink.py index 3e7091388..1d1f857ad 100644 --- a/modules/background_tasks/background_tasks/log_sink.py +++ b/modules/background_tasks/background_tasks/log_sink.py @@ -43,8 +43,13 @@ _handler: QueueSinkHandler | None = None +def _clean(value: object, limit: int) -> str: + """*value* as text with NULs removed (Postgres rejects them, losing the whole batch).""" + return str(value).replace("\x00", "")[:limit] + + def _text(value: object, limit: int) -> str | None: - return None if value in (None, "") else str(value)[:limit] + return None if value in (None, "") else _clean(value, limit) def _extra(record: logging.LogRecord) -> dict[str, Any]: @@ -54,10 +59,10 @@ def _extra(record: logging.LogRecord) -> dict[str, Any]: continue if len(extra) >= LOG_EXTRA_MAX_KEYS: break - extra[key[:LOG_NAME_MAX_LENGTH]] = ( + extra[_clean(key, LOG_NAME_MAX_LENGTH)] = ( value if isinstance(value, bool | int | float) - else str(value)[:LOG_EXTRA_VALUE_MAX_LENGTH] + else _clean(value, LOG_EXTRA_VALUE_MAX_LENGTH) ) return extra @@ -67,8 +72,8 @@ def to_row(record: logging.LogRecord) -> RetainedLog: return RetainedLog( ts=datetime.fromtimestamp(record.created, UTC), level=record.levelno, - logger=record.name[:LOG_NAME_MAX_LENGTH], - message=str(record.msg)[:LOG_MESSAGE_MAX_LENGTH], + logger=_clean(record.name, LOG_NAME_MAX_LENGTH), + message=_clean(record.msg, LOG_MESSAGE_MAX_LENGTH), task_id=_text(getattr(record, "task_id", None), LOG_ID_MAX_LENGTH), job_id=_text(getattr(record, "job_id", None), LOG_ID_MAX_LENGTH), tenant_id=_text(getattr(record, "tenant_id", None), TENANT_ID_MAX_LENGTH), 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..859d18f31 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 { @@ -47,15 +49,15 @@ export function WorkerHealthBanner({ backlog }: Props) { 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 */ - }); + apiResult(api.GET('/api/background_tasks/admin/workers', { signal: controller.signal })).then( + (result) => { + // Offline, forbidden or unmounted — say nothing rather than guess. + if (!controller.signal.aborted && result.ok) { + setState(diagnose(result.data as WorkerSnapshot)); + } + }, + ); + // Unmount or a changed backlog cancels the slow broker probe in flight. return () => controller.abort(); }, [backlog]); 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..ee2798f4a --- /dev/null +++ b/modules/background_tasks/tests-js/background_tasks_WorkerHealthBanner.test.tsx @@ -0,0 +1,63 @@ +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('aborts the in-flight request on unmount', async () => { + let signal: AbortSignal | undefined; + const fetchMock = vi.fn(async (req: Request) => { + signal = req.signal; + return new Promise(() => {}); + }); + vi.stubGlobal('fetch', fetchMock); + const { unmount } = render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(signal?.aborted).toBe(false); + unmount(); + expect(signal?.aborted).toBe(true); + }); + + 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 }; + }); +} diff --git a/modules/background_tasks/tests/test_bg_inprocess_sweep.py b/modules/background_tasks/tests/test_bg_inprocess_sweep.py index 11a50daec..47b91f755 100644 --- a/modules/background_tasks/tests/test_bg_inprocess_sweep.py +++ b/modules/background_tasks/tests/test_bg_inprocess_sweep.py @@ -18,6 +18,20 @@ from sqlalchemy import select +@pytest.fixture +def settings(settings, tmp_path): + """Run on a file database, not the default in-memory one. + + An in-memory SQLite engine holds ONE shared connection. The sweep loop and + the test's polling session then run on the same DBAPI connection, so a poll + closing its session rolls back the sweeper's uncommitted DELIVERED update + (row stuck RUNNING under a 300 s lease), and a cancelled poll invalidates the + connection so the next checkout is an empty database ("no such table"). + A file database gives each session its own connection, like production. + """ + return settings.model_copy(update={"database_url": f"sqlite+aiosqlite:///{tmp_path}/sweep.db"}) + + @pytest.fixture(autouse=True) def eager_mode(monkeypatch): monkeypatch.setenv("SM_BG_TASKS_TASK_ALWAYS_EAGER", "true") @@ -47,7 +61,6 @@ async def seed(app, *, crashed=False): async def restart_short(app): services = app.state.background_tasks - await stop_sweep(services) # Validation stays >= 1 in production; shorten only this test's clock. services.settings.outbox_sweep_interval_seconds = 0.05 start_sweep( @@ -69,6 +82,9 @@ async def handler(event, ctx): seen.append(event.value) app.state.sm.event_bus.subscribe_durable(SweepPing, handler, name="sweep-test") + # Quiesce the lifespan's sweeper BEFORE seeding, so a tick cannot claim the row + # and then be cancelled mid-delivery, stranding it RUNNING under a live lease. + await stop_sweep(app.state.background_tasks) row_id = await seed(app, crashed=crashed) task = await restart_short(app) diff --git a/modules/background_tasks/tests/test_bg_log_sink_nul.py b/modules/background_tasks/tests/test_bg_log_sink_nul.py new file mode 100644 index 000000000..6960b96f9 --- /dev/null +++ b/modules/background_tasks/tests/test_bg_log_sink_nul.py @@ -0,0 +1,26 @@ +"""NUL characters are stripped from retained logs (Postgres text/JSON rejects them).""" + +from __future__ import annotations + +import logging + +from background_tasks import log_sink + + +def test_to_row_strips_nul_from_message_ids_and_extra(): + record = logging.LogRecord("app\x00x", logging.INFO, __file__, 1, "bad\x00msg", None, None) + record.task_id = "t\x00id" + record.job_id = "j\x00id" + record.tenant_id = "ten\x00ant" + record.correlation_id = "c\x00id" + record.custom = "va\x00lue" + + row = log_sink.to_row(record) + + assert row.message == "badmsg" + assert row.task_id == "tid" + assert row.job_id == "jid" + assert row.tenant_id == "tenant" + assert row.correlation_id == "cid" + assert "\x00" not in str(row.extra) + assert "\x00" not in row.logger 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/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/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/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(); + }); +}); diff --git a/modules/dashboard/dashboard/locales/es.json b/modules/dashboard/dashboard/locales/es.json index ca4421045..a2349884e 100644 --- a/modules/dashboard/dashboard/locales/es.json +++ b/modules/dashboard/dashboard/locales/es.json @@ -84,5 +84,6 @@ "nav": { "dashboard": "Panel", "doctor": "Doctor" - } + }, + "permission_group": "Panel" } diff --git a/modules/file_storage/file_storage/endpoints/public.py b/modules/file_storage/file_storage/endpoints/public.py index 9f853c738..55f0f6f66 100644 --- a/modules/file_storage/file_storage/endpoints/public.py +++ b/modules/file_storage/file_storage/endpoints/public.py @@ -47,14 +47,31 @@ async def public_thumbnail( ) +async def _serve_public( + service: FileStorageService, file_id: uuid.UUID, request: Request, t: TranslatorDep +) -> Response: + row = await _public_row(service, file_id, t) + return await public_file_response(service, row, request, t) + + +# Two functions, not stacked decorators: the operationId is derived from the +# function name, so sharing one would give both routes the same id. @router.get(constants.PATH_PUBLIC, response_model=None) -@router.get(constants.PATH_PUBLIC_NAMED, response_model=None) async def public_file( file_id: uuid.UUID, request: Request, t: TranslatorDep, - filename: str | None = None, # cosmetic: lets the URL end in a readable name service: FileStorageService = Depends(get_file_storage_service), ) -> Response: - row = await _public_row(service, file_id, t) - return await public_file_response(service, row, request, t) + return await _serve_public(service, file_id, request, t) + + +@router.get(constants.PATH_PUBLIC_NAMED, response_model=None) +async def public_file_named( + file_id: uuid.UUID, + request: Request, + t: TranslatorDep, + filename: str, # cosmetic: lets the URL end in a readable name + service: FileStorageService = Depends(get_file_storage_service), +) -> Response: + return await _serve_public(service, file_id, request, t) 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..40ed1a9fe --- /dev/null +++ b/modules/file_storage/file_storage/pages/api.generated.ts @@ -0,0 +1,1050 @@ +// 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}": { + 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}/{filename}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Public File Named */ + get: operations["file_storage_public_file_named"]; + 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; + }; + 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_named: { + parameters: { + query?: never; + header?: never; + path: { + file_id: string; + filename: 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..d2d0c1811 100644 --- a/modules/file_storage/file_storage/pages/resumable-upload.ts +++ b/modules/file_storage/file_storage/pages/resumable-upload.ts @@ -1,5 +1,11 @@ +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'; +import { + parseFailureReason, + reasonFromBody, + UploadAbortedError, + UploadFailedError, +} from './upload-errors'; /** Parts in flight at once, per file. */ export const PART_CONCURRENCY = 4; @@ -30,20 +36,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, + reasonFromBody(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 +59,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 +73,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 +190,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 +218,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/upload-errors.ts b/modules/file_storage/file_storage/pages/upload-errors.ts index ef7f7fd7e..66395f2a8 100644 --- a/modules/file_storage/file_storage/pages/upload-errors.ts +++ b/modules/file_storage/file_storage/pages/upload-errors.ts @@ -14,6 +14,11 @@ export function parseFailureReason(body: string): string | undefined { } catch { return undefined; } + return reasonFromBody(parsed); +} + +/** `parseFailureReason` for a body that is already parsed (the API client's `error`). */ +export function reasonFromBody(parsed: unknown): string | undefined { const detail = (parsed as { detail?: unknown } | null)?.detail; if (typeof detail === 'string') return detail || undefined; const message = (detail as { message?: unknown } | null)?.message; 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()); 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/locales/de.json b/modules/settings/settings/locales/de.json index d721e102d..b6e8b6cc3 100644 --- a/modules/settings/settings/locales/de.json +++ b/modules/settings/settings/locales/de.json @@ -114,6 +114,7 @@ "test_connection": "Verbindung zu {name} testen", "test_connection_generic": "Verbindung testen", "testing": "Wird getestet…", + "test_failed": "Der Verbindungstest konnte nicht ausgeführt werden.", "last_test_success": "Letzter Test erfolgreich {ago}", "last_test_failure": "Letzter Test fehlgeschlagen {ago}", "env_var_hint": "Name der Umgebungsvariable für dieses Feld, verwendet von „smpy settings import-from-env“", 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..de4495143 --- /dev/null +++ b/modules/settings/settings/pages/api.generated.ts @@ -0,0 +1,1043 @@ +// 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; + /** + * Label Key + * @default + */ + label_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/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/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/modules/tenants/tenants/components/InviteForm.tsx b/modules/tenants/tenants/components/InviteForm.tsx index fd72d31bf..463273f98 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; /** @@ -32,14 +36,21 @@ export function InviteForm({ onInvited }: Props) { const [role, setRole] = useState<'admin' | 'member'>('member'); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [emailInvalid, setEmailInvalid] = useState(false); const [acceptUrl, setAcceptUrl] = useState(null); const [delivery, setDelivery] = useState({ emailed: false, email: '' }); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); + if (!EMAIL_SHAPE.test(email.trim())) { + setError(t(keys.tenants.members.invite_email_invalid)); + setEmailInvalid(true); + return; + } setSubmitting(true); setError(null); + setEmailInvalid(false); // A new attempt replaces the previous result; a failure must not leave the // last invitation's link and "emailed" banner beside its error. setAcceptUrl(null); @@ -76,16 +87,23 @@ export function InviteForm({ onInvited }: Props) { {t(keys.tenants.members.invite_title)} -
+
setEmail(e.target.value)} + onChange={(e) => { + setEmail(e.target.value); + if (emailInvalid) { + setEmailInvalid(false); + setError(null); + } + }} placeholder={t(keys.tenants.members.invite_email_placeholder)} required + aria-invalid={emailInvalid ? 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 (
-