From 62d01b5097c2662ed6397c5ce5a3f7e97c191b12 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Sat, 12 Sep 2026 07:36:05 +0200 Subject: [PATCH] feat: router with RJSF workflows and access management UI --- PLAN_NEXT.md | 41 +++- frontend/package-lock.json | 273 +++++++++++++++++++++++- frontend/package.json | 5 +- frontend/src/App.tsx | 81 ++++--- frontend/src/api.ts | 65 +++++- frontend/src/auth.tsx | 55 +++++ frontend/src/components/AccessQueue.tsx | 90 ++++++++ frontend/src/components/FlowsTab.tsx | 105 --------- frontend/src/main.tsx | 5 +- frontend/src/pages/AccessPage.tsx | 88 ++++++++ frontend/src/pages/AdminPage.tsx | 32 +++ frontend/src/pages/LoginPage.tsx | 44 ++++ frontend/src/pages/StartPage.tsx | 26 +++ frontend/src/pages/TasksPage.tsx | 45 ++++ frontend/src/pages/WorkflowsPage.tsx | 137 ++++++++++++ 15 files changed, 948 insertions(+), 144 deletions(-) create mode 100644 frontend/src/auth.tsx create mode 100644 frontend/src/components/AccessQueue.tsx delete mode 100644 frontend/src/components/FlowsTab.tsx create mode 100644 frontend/src/pages/AccessPage.tsx create mode 100644 frontend/src/pages/AdminPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/StartPage.tsx create mode 100644 frontend/src/pages/TasksPage.tsx create mode 100644 frontend/src/pages/WorkflowsPage.tsx diff --git a/PLAN_NEXT.md b/PLAN_NEXT.md index ace771c..dc760ca 100644 --- a/PLAN_NEXT.md +++ b/PLAN_NEXT.md @@ -41,11 +41,9 @@ build`, `docker compose config`, container rebuild. CI covers all three pytest` (attached api, from `backend/`), `docker compose exec frontend npm run build`, `docker compose config --quiet`. -**Still open (docs-only):** `README.md` + `.env.example` uncommitted diff -describes the target vision (access mgmt, dynamic forms, new Web UI) that is -NOT yet implemented: `backend/components/kirche/` is -empty, frontend is still the old 3-tab UI, no `input-schema` endpoint. -(Phase 1 backend auth IS implemented since this was written.) +**Still open:** nothing from the original list — Phases 1–4 are implemented +(see §0.2). Remaining eyeball checks: `kirche` category in the Langflow +palette, RJSF form UX in a browser, magic-link mail with real SMTP. ## 0.1 Follow-up fixes (commits after Phase 0) @@ -73,10 +71,39 @@ auth` + the LFX/stack repair commit: startup sync deploys `backend/flows` to LFX, `uv run pytest` 60 green. Before Reopen in Container: `docker compose down` any manual stack (same project name → port clash). +## 0.2 Phases 1–4 (done, with deviations) -## 1. Locked decisions (do not re-ask) +* Phase 1 auth as planned (`app/auth/*`, migration `a3f1c9e2b4d7`). + Additionally fixed: `settings.database_url` default is a real + `sqlite:///` URL (bare path crashed `create_engine`/alembic). +* Phase 2: `CustomInputForm` has the 10 planned inputs **plus one hidden + `form_values: MessageTextInput (show=False)` plumbing input**. Without + it, submitted web-form values never reached the run (LFX routes run + input to entry nodes; scalar fields are not connectable). Fixture flow + `intake_form.json`: `ChatInput → form.form_values`, + `form.form_data → ChatOutput`; `collect()` merges the envelope + (`{"payload": {...}}` unwrapped like the other components) over field + defaults. Verified live: run with values returns them in the output. +* Phase 2 generator caveat: lfx 1.12.x `to_frontend_node()` **cannot + re-instantiate `MultiselectInput`** (emits `list`, constructor forbids + extras — their own builtin `SearXNGToolComponent` fails identically). + `kirche_node()` therefore replicates the pre-validation dict build and + appends the `code` field by hand. If a future lfx fixes this, simplify + back to plain `to_frontend_node()`. +* Phase 3 as planned (`input_schema.py`, `FlowVersion.input_schema`, + migration `b7e2d4a1c9f3`, `GET input-schema`). Deviations: schema is + cached in `store.create_version` (single choke point: sync, import, API + all covered); parser skips `show: false` fields; enum-mismatched + defaults omitted; endpoint maps `InvalidError` → 404 too + (`get_latest_deployed_version` raises it, not `NotFoundError`). +* Phase 4 as planned (`react-router-dom@7`, `@rjsf/core` + + `@rjsf/validator-ajv8` (v6), English routes, German labels, + `credentials: include`, old tab shell dropped). `FlowsTab.tsx` deleted; + `ApprovalsTab`/`AdminTab` reused inside Tasks/Admin pages. Verified: + `npm run build` green, routes + `/api` proxy return 200, served schema + validates under JSON-Schema Draft 7. -* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`, +## 1. Locked decisions (do not re-ask)* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`, store `sha256`, `used_at` set before session creation), session = opaque id in **DB table**, cookie `HttpOnly; Secure; SameSite=Lax; Path=/`. SMTP creds already in `.env` (`SMTP_HOST/PORT/USERNAME/PASSWORD`, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7315980..4719b52 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,11 @@ "name": "kirche-einfach-frontend", "version": "0.1.0", "dependencies": { + "@rjsf/core": "^6.10.0", + "@rjsf/validator-ajv8": "^6.10.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-router-dom": "^7.18.3" }, "devDependencies": { "@types/react": "^19.0.0", @@ -810,6 +813,58 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@rjsf/core": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.0.tgz", + "integrity": "sha512-fdyaPnhIe+NzTFZcYcYcp/QqVcAJhhloIsU6vN+hp/IhiGJrzgB9REMVVUlKYNzP37xZ/BsEq7u7RC24KKdLAQ==", + "license": "Apache-2.0", + "dependencies": { + "markdown-to-jsx": "^9.8.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.10.0", + "react": ">=18" + } + }, + "node_modules/@rjsf/utils": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz", + "integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@x0k/json-schema-merge": "^1.0.3", + "fast-equals": "^6.0.0", + "fast-uri": "^4.1.4", + "jsonpointer": "^5.0.1", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@rjsf/validator-ajv8": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.0.tgz", + "integrity": "sha512-B/cNuPIQNBiJo4ByoJUXwGylu51JA5Jhkj4gsZ794dl85xxfcQ43HKfR1Lg55SoCUOzHc0Sf7zI/0q3R3sKEEQ==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^2.1.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.10.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1219,6 +1274,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -1260,6 +1322,65 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@x0k/json-schema-merge": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.5.tgz", + "integrity": "sha512-bPvwYpTkhBOXkvbC8KMNhkgzv+gWu6Yx6FVho3mL+vMf8Fy3gdn9YrlrBrXphznvOHhB85BudH/ywtVRZ/HG3A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", @@ -1335,6 +1456,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1419,6 +1553,39 @@ "node": ">=6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.3.tgz", + "integrity": "sha512-IECu4C0fFZFoUC2cA2rDaNUuVIfWIZMwh3tY+ng924vOuD9OaUFFrcIrVwCKbV7TJDkGmrHfhwZ7R0AnHfVhyw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-uri": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1482,6 +1649,12 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1495,6 +1668,16 @@ "node": ">=6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1505,6 +1688,34 @@ "yallist": "^3.0.2" } }, + "node_modules/markdown-to-jsx": { + "version": "9.10.2", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz", + "integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "react": ">= 16.0.0", + "solid-js": ">=1.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1611,6 +1822,13 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1621,6 +1839,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -1683,6 +1948,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 03a8bf0..32ac6c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,8 +9,11 @@ "preview": "vite preview" }, "dependencies": { + "@rjsf/core": "^6.10.0", + "@rjsf/validator-ajv8": "^6.10.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-router-dom": "^7.18.3" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8ebbdb..7d2c87e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,39 +1,66 @@ -import { useState } from "react"; -import AdminTab from "./components/AdminTab"; -import ApprovalsTab from "./components/ApprovalsTab"; -import FlowsTab from "./components/FlowsTab"; +import { Link, Route, Routes, useNavigate } from "react-router-dom"; +import { AuthProvider, useAuth } from "./auth"; +import StartPage from "./pages/StartPage"; +import AccessPage from "./pages/AccessPage"; +import LoginPage from "./pages/LoginPage"; +import WorkflowsPage from "./pages/WorkflowsPage"; +import TasksPage from "./pages/TasksPage"; +import AdminPage from "./pages/AdminPage"; -type Tab = "flows" | "approvals" | "admin"; +const navStyle: React.CSSProperties = { + display: "flex", + gap: 12, + marginBottom: 16, + alignItems: "center", +}; -const tabStyle = (active: boolean): React.CSSProperties => ({ - fontWeight: active ? "bold" : "normal", - textDecoration: active ? "underline" : "none", -}); +function Shell() { + const { me, loading, logout } = useAuth(); + const navigate = useNavigate(); -export default function App() { - const [tab, setTab] = useState("flows"); + async function onLogout() { + await logout(); + navigate("/"); + } return (

Kirche Einfach

KI-gestützte Verwaltungsplattform für Kirche

-
); } + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 763ad9f..d913761 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -69,6 +69,33 @@ export interface SyncReport { errors: Record; } +export interface Me { + id: string; + email: string; + name: string; + role: string; + tenant_id: string; +} + +export interface AccessRequest { + id: string; + name: string; + email: string; + reason: string; + status: string; + created_at: string; + decided_at: string | null; + decided_by: string | null; +} + +export interface InputSchema { + flow_id: string; + version_no: number; + json_schema: Record; + ui_schema: Record; + fallback: string | null; +} + export class ApiError extends Error { status: number; constructor(status: number, message: string) { @@ -79,6 +106,8 @@ export class ApiError extends Error { async function req(path: string, init?: RequestInit): Promise { const res = await fetch(path, { + // Session cookie (HttpOnly) must travel with every request. + credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); @@ -114,11 +143,14 @@ export const api = { req(`/api/flows/${flowId}/versions/${versionNo}/deploy`, { method: "POST", }), - runFlow: (flowId: string, input: unknown, tenantId: string) => + runFlow: (flowId: string, input: unknown) => req(`/api/flows/${flowId}/run`, { method: "POST", - body: JSON.stringify({ input, tenant_id: tenantId }), + // Tenant comes from the session server-side; no tenant box anymore. + body: JSON.stringify({ input }), }), + getInputSchema: (flowId: string) => + req(`/api/flows/${flowId}/input-schema`), listRuns: (flowId: string) => req(`/api/flows/${flowId}/runs`), listApprovals: () => req("/api/approvals"), decide: ( @@ -148,4 +180,33 @@ export const api = { req("/api/flows/sync", { method: "POST", }), + me: async (): Promise => { + try { + return await req("/api/auth/me"); + } catch (e) { + if (e instanceof ApiError && e.status === 401) return null; + throw e; + } + }, + consumeMagic: (token: string) => + req("/api/auth/magic/consume", { + method: "POST", + body: JSON.stringify({ token }), + }), + logout: () => + req<{ message: string }>("/api/auth/logout", { method: "POST" }), + requestAccess: (name: string, email: string, reason: string) => + req("/api/access/requests", { + method: "POST", + body: JSON.stringify({ name, email, reason }), + }), + listAccessRequests: (status?: string) => + req( + `/api/access/requests${status ? `?status=${status}` : ""}`, + ), + decideAccess: (id: string, approve: boolean) => + req<{ request: AccessRequest; email_sent: boolean }>( + `/api/access/requests/${id}/${approve ? "approve" : "reject"}`, + { method: "POST" }, + ), }; diff --git a/frontend/src/auth.tsx b/frontend/src/auth.tsx new file mode 100644 index 0000000..8a94799 --- /dev/null +++ b/frontend/src/auth.tsx @@ -0,0 +1,55 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { api, type Me } from "./api"; + +interface Auth { + me: Me | null; + loading: boolean; + refresh: () => Promise; + logout: () => Promise; +} + +const AuthContext = createContext({ + me: null, + loading: true, + refresh: async () => {}, + logout: async () => {}, +}); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [me, setMe] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + setMe(await api.me()); + } finally { + setLoading(false); + } + }, []); + + const logout = useCallback(async () => { + await api.logout(); + setMe(null); + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return ( + + {children} + + ); +} + +export function useAuth(): Auth { + return useContext(AuthContext); +} diff --git a/frontend/src/components/AccessQueue.tsx b/frontend/src/components/AccessQueue.tsx new file mode 100644 index 0000000..74e9657 --- /dev/null +++ b/frontend/src/components/AccessQueue.tsx @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useState } from "react"; +import { ApiError, api, type AccessRequest } from "../api"; + +export default function AccessQueue() { + const [requests, setRequests] = useState([]); + const [error, setError] = useState(""); + const [info, setInfo] = useState(""); + const [busyId, setBusyId] = useState(""); + + const load = useCallback(async () => { + try { + setRequests(await api.listAccessRequests()); + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function decide(id: string, approve: boolean) { + setBusyId(id); + setError(""); + setInfo(""); + try { + const res = await api.decideAccess(id, approve); + setRequests((prev) => + prev.map((r) => (r.id === id ? res.request : r)), + ); + if (approve && !res.email_sent) { + setInfo("Genehmigt — E-Mail-Versand ist nicht konfiguriert."); + } + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); + } finally { + setBusyId(""); + } + } + + const pending = requests.filter((r) => r.status === "pending"); + const done = requests.filter((r) => r.status !== "pending"); + + return ( +
+

Zugangsanfragen

+ {error &&

{error}

} + {info &&

{info}

} + {pending.length === 0 &&

Keine offenen Anfragen.

} +
    + {pending.map((r) => ( +
  • + {r.name} · {r.email} + {r.reason && ( + <> +
    + {r.reason} + + )} +
    + {" "} + +
  • + ))} +
+ {done.length > 0 && ( + <> +

Entschieden ({done.length})

+
    + {done.map((r) => ( +
  • + {r.status} · {r.name} · {r.email} +
  • + ))} +
+ + )} +
+ ); +} diff --git a/frontend/src/components/FlowsTab.tsx b/frontend/src/components/FlowsTab.tsx deleted file mode 100644 index 98cfdbe..0000000 --- a/frontend/src/components/FlowsTab.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { ApiError, api, type FlowDefinition, type Run } from "../api"; - -const inputStyle: React.CSSProperties = { - width: "100%", - fontFamily: "monospace", - fontSize: 13, -}; - -export default function FlowsTab() { - const [flows, setFlows] = useState([]); - const [flowId, setFlowId] = useState(""); - const [inputJson, setInputJson] = useState( - '{\n "betrag": 120000,\n "schwelle": 200000\n}', - ); - const [tenant, setTenant] = useState("default"); - const [runs, setRuns] = useState([]); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - - const loadFlows = useCallback(async () => { - try { - const list = await api.listFlows(); - setFlows(list); - const first = list[0]; - if (first && !flowId) setFlowId(first.id); - } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); - } - }, [flowId]); - - const loadRuns = useCallback(async (id: string) => { - if (!id) return; - try { - setRuns(await api.listRuns(id)); - } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); - } - }, []); - - useEffect(() => { - void loadFlows(); - }, [loadFlows]); - - useEffect(() => { - void loadRuns(flowId); - }, [flowId, loadRuns]); - - async function onRun() { - setBusy(true); - setError(""); - try { - const payload: unknown = JSON.parse(inputJson); - const run = await api.runFlow(flowId, payload, tenant); - setRuns((prev) => [run, ...prev]); - } catch (e) { - if (e instanceof SyntaxError) setError("Ungültiges JSON in der Eingabe."); - else setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); - } finally { - setBusy(false); - } - } - - return ( -
-

Vorgang starten

- {error &&

{error}

} -
- - setTenant(e.target.value)} - placeholder="Mandant" - style={{ width: 160 }} - /> - -
-