feat: router with RJSF workflows and access management UI
This commit is contained in:
parent
46ecab00db
commit
62d01b5097
15 changed files with 948 additions and 144 deletions
41
PLAN_NEXT.md
41
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
|
pytest` (attached api, from `backend/`), `docker compose exec frontend npm
|
||||||
run build`, `docker compose config --quiet`.
|
run build`, `docker compose config --quiet`.
|
||||||
|
|
||||||
**Still open (docs-only):** `README.md` + `.env.example` uncommitted diff
|
**Still open:** nothing from the original list — Phases 1–4 are implemented
|
||||||
describes the target vision (access mgmt, dynamic forms, new Web UI) that is
|
(see §0.2). Remaining eyeball checks: `kirche` category in the Langflow
|
||||||
NOT yet implemented: `backend/components/kirche/` is
|
palette, RJSF form UX in a browser, magic-link mail with real SMTP.
|
||||||
empty, frontend is still the old 3-tab UI, no `input-schema` endpoint.
|
|
||||||
(Phase 1 backend auth IS implemented since this was written.)
|
|
||||||
|
|
||||||
## 0.1 Follow-up fixes (commits after Phase 0)
|
## 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.
|
startup sync deploys `backend/flows` to LFX, `uv run pytest` 60 green.
|
||||||
Before Reopen in Container: `docker compose down` any manual stack
|
Before Reopen in Container: `docker compose down` any manual stack
|
||||||
(same project name → port clash).
|
(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
|
store `sha256`, `used_at` set before session creation), session = opaque id
|
||||||
in **DB table**, cookie `HttpOnly; Secure; SameSite=Lax; Path=/`.
|
in **DB table**, cookie `HttpOnly; Secure; SameSite=Lax; Path=/`.
|
||||||
SMTP creds already in `.env` (`SMTP_HOST/PORT/USERNAME/PASSWORD`,
|
SMTP creds already in `.env` (`SMTP_HOST/PORT/USERNAME/PASSWORD`,
|
||||||
|
|
|
||||||
273
frontend/package-lock.json
generated
273
frontend/package-lock.json
generated
|
|
@ -8,8 +8,11 @@
|
||||||
"name": "kirche-einfach-frontend",
|
"name": "kirche-einfach-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rjsf/core": "^6.10.0",
|
||||||
|
"@rjsf/validator-ajv8": "^6.10.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|
@ -810,6 +813,58 @@
|
||||||
"node": "^22.20 || ^24.12 || >=25"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -1219,6 +1274,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"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"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.11.21",
|
"version": "2.11.21",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
|
||||||
|
|
@ -1335,6 +1456,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
|
@ -1419,6 +1553,39 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
|
@ -1482,6 +1649,12 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
|
|
@ -1495,6 +1668,16 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
|
@ -1505,6 +1688,34 @@
|
||||||
"yallist": "^3.0.2"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -1611,6 +1822,13 @@
|
||||||
"react": "^19.2.8"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|
@ -1621,6 +1839,53 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.63.1",
|
"version": "4.63.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz",
|
||||||
|
|
@ -1683,6 +1948,12 @@
|
||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@rjsf/core": "^6.10.0",
|
||||||
|
"@rjsf/validator-ajv8": "^6.10.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,66 @@
|
||||||
import { useState } from "react";
|
import { Link, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
import AdminTab from "./components/AdminTab";
|
import { AuthProvider, useAuth } from "./auth";
|
||||||
import ApprovalsTab from "./components/ApprovalsTab";
|
import StartPage from "./pages/StartPage";
|
||||||
import FlowsTab from "./components/FlowsTab";
|
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 => ({
|
function Shell() {
|
||||||
fontWeight: active ? "bold" : "normal",
|
const { me, loading, logout } = useAuth();
|
||||||
textDecoration: active ? "underline" : "none",
|
const navigate = useNavigate();
|
||||||
});
|
|
||||||
|
|
||||||
export default function App() {
|
async function onLogout() {
|
||||||
const [tab, setTab] = useState<Tab>("flows");
|
await logout();
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16 }}>
|
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16 }}>
|
||||||
<h1>Kirche Einfach</h1>
|
<h1>Kirche Einfach</h1>
|
||||||
<p>KI-gestützte Verwaltungsplattform für Kirche</p>
|
<p>KI-gestützte Verwaltungsplattform für Kirche</p>
|
||||||
<nav style={{ display: "flex", gap: 12, marginBottom: 16 }}>
|
<nav style={navStyle}>
|
||||||
<button style={tabStyle(tab === "flows")} onClick={() => setTab("flows")}>
|
<Link to="/">Start</Link>
|
||||||
Vorgänge
|
{!me && <Link to="/access">Zugang</Link>}
|
||||||
</button>
|
{me && (
|
||||||
<button
|
<>
|
||||||
style={tabStyle(tab === "approvals")}
|
<Link to="/app/workflows">Workflows</Link>
|
||||||
onClick={() => setTab("approvals")}
|
<Link to="/app/tasks">Aufgaben</Link>
|
||||||
>
|
<Link to="/app/admin">Verwaltung</Link>
|
||||||
Freigaben
|
<span style={{ marginLeft: "auto" }}>{me.email}</span>
|
||||||
</button>
|
<button onClick={() => void onLogout()}>Abmelden</button>
|
||||||
<button style={tabStyle(tab === "admin")} onClick={() => setTab("admin")}>
|
</>
|
||||||
Administration
|
)}
|
||||||
</button>
|
|
||||||
</nav>
|
</nav>
|
||||||
{tab === "flows" && <FlowsTab />}
|
{loading ? (
|
||||||
{tab === "approvals" && <ApprovalsTab />}
|
<p>Wird geladen …</p>
|
||||||
{tab === "admin" && <AdminTab />}
|
) : (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<StartPage />} />
|
||||||
|
<Route path="/access" element={<AccessPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/app/workflows" element={<WorkflowsPage />} />
|
||||||
|
<Route path="/app/tasks" element={<TasksPage />} />
|
||||||
|
<Route path="/app/admin" element={<AdminPage />} />
|
||||||
|
<Route path="*" element={<StartPage />} />
|
||||||
|
</Routes>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Shell />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,33 @@ export interface SyncReport {
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
ui_schema: Record<string, unknown>;
|
||||||
|
fallback: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
constructor(status: number, message: string) {
|
constructor(status: number, message: string) {
|
||||||
|
|
@ -79,6 +106,8 @@ export class ApiError extends Error {
|
||||||
|
|
||||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
|
// Session cookie (HttpOnly) must travel with every request.
|
||||||
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
|
|
@ -114,11 +143,14 @@ export const api = {
|
||||||
req<FlowVersion>(`/api/flows/${flowId}/versions/${versionNo}/deploy`, {
|
req<FlowVersion>(`/api/flows/${flowId}/versions/${versionNo}/deploy`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
runFlow: (flowId: string, input: unknown, tenantId: string) =>
|
runFlow: (flowId: string, input: unknown) =>
|
||||||
req<Run>(`/api/flows/${flowId}/run`, {
|
req<Run>(`/api/flows/${flowId}/run`, {
|
||||||
method: "POST",
|
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<InputSchema>(`/api/flows/${flowId}/input-schema`),
|
||||||
listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`),
|
listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`),
|
||||||
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
|
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
|
||||||
decide: (
|
decide: (
|
||||||
|
|
@ -148,4 +180,33 @@ export const api = {
|
||||||
req<SyncReport>("/api/flows/sync", {
|
req<SyncReport>("/api/flows/sync", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
|
me: async (): Promise<Me | null> => {
|
||||||
|
try {
|
||||||
|
return await req<Me>("/api/auth/me");
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401) return null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
consumeMagic: (token: string) =>
|
||||||
|
req<Me>("/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<AccessRequest>("/api/access/requests", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, email, reason }),
|
||||||
|
}),
|
||||||
|
listAccessRequests: (status?: string) =>
|
||||||
|
req<AccessRequest[]>(
|
||||||
|
`/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" },
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
55
frontend/src/auth.tsx
Normal file
55
frontend/src/auth.tsx
Normal file
|
|
@ -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<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<Auth>({
|
||||||
|
me: null,
|
||||||
|
loading: true,
|
||||||
|
refresh: async () => {},
|
||||||
|
logout: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [me, setMe] = useState<Me | null>(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 (
|
||||||
|
<AuthContext.Provider value={{ me, loading, refresh, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): Auth {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
90
frontend/src/components/AccessQueue.tsx
Normal file
90
frontend/src/components/AccessQueue.tsx
Normal file
|
|
@ -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<AccessRequest[]>([]);
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h3>Zugangsanfragen</h3>
|
||||||
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
|
{info && <p style={{ color: "green" }}>{info}</p>}
|
||||||
|
{pending.length === 0 && <p>Keine offenen Anfragen.</p>}
|
||||||
|
<ul>
|
||||||
|
{pending.map((r) => (
|
||||||
|
<li key={r.id} style={{ marginBottom: 8 }}>
|
||||||
|
<strong>{r.name}</strong> · {r.email}
|
||||||
|
{r.reason && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
{r.reason}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<br />
|
||||||
|
<button
|
||||||
|
onClick={() => void decide(r.id, true)}
|
||||||
|
disabled={busyId === r.id}
|
||||||
|
>
|
||||||
|
Genehmigen
|
||||||
|
</button>{" "}
|
||||||
|
<button
|
||||||
|
onClick={() => void decide(r.id, false)}
|
||||||
|
disabled={busyId === r.id}
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{done.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h4>Entschieden ({done.length})</h4>
|
||||||
|
<ul>
|
||||||
|
{done.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<strong>{r.status}</strong> · {r.name} · {r.email}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<FlowDefinition[]>([]);
|
|
||||||
const [flowId, setFlowId] = useState("");
|
|
||||||
const [inputJson, setInputJson] = useState(
|
|
||||||
'{\n "betrag": 120000,\n "schwelle": 200000\n}',
|
|
||||||
);
|
|
||||||
const [tenant, setTenant] = useState("default");
|
|
||||||
const [runs, setRuns] = useState<Run[]>([]);
|
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<h2>Vorgang starten</h2>
|
|
||||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
|
||||||
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
|
|
||||||
<select value={flowId} onChange={(e) => setFlowId(e.target.value)}>
|
|
||||||
<option value="">— Flow wählen —</option>
|
|
||||||
{flows.map((f) => (
|
|
||||||
<option key={f.id} value={f.id}>
|
|
||||||
{f.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
value={tenant}
|
|
||||||
onChange={(e) => setTenant(e.target.value)}
|
|
||||||
placeholder="Mandant"
|
|
||||||
style={{ width: 160 }}
|
|
||||||
/>
|
|
||||||
<button onClick={() => void onRun()} disabled={!flowId || busy}>
|
|
||||||
{busy ? "Läuft …" : "Starten"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
rows={6}
|
|
||||||
style={inputStyle}
|
|
||||||
value={inputJson}
|
|
||||||
onChange={(e) => setInputJson(e.target.value)}
|
|
||||||
/>
|
|
||||||
<h3>Letzte Läufe</h3>
|
|
||||||
{runs.length === 0 && <p>Noch keine Läufe.</p>}
|
|
||||||
<ul>
|
|
||||||
{runs.map((r) => (
|
|
||||||
<li key={r.id}>
|
|
||||||
<strong>{r.status}</strong> · {r.tenant_id} ·{" "}
|
|
||||||
<code>{r.output?.result ?? r.output?.error ?? "–"}</code>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
88
frontend/src/pages/AccessPage.tsx
Normal file
88
frontend/src/pages/AccessPage.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ApiError, api } from "../api";
|
||||||
|
|
||||||
|
export default function AccessPage() {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api.requestAccess(name.trim(), email.trim(), reason.trim());
|
||||||
|
setDone(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.status === 429
|
||||||
|
? "Zu viele Anfragen von dieser Adresse — bitte später erneut versuchen."
|
||||||
|
: `${err.status}: ${err.message}`
|
||||||
|
: String(err),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Zugang</h2>
|
||||||
|
<p>
|
||||||
|
Danke! Ihre Anfrage ist eingegangen. Sie erhalten eine E-Mail, sobald
|
||||||
|
sie freigegeben wurde.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Zugang anfragen</h2>
|
||||||
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => void onSubmit(e)}
|
||||||
|
style={{ display: "grid", gap: 8, maxWidth: 480 }}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={200}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
maxLength={320}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wofür brauchen Sie Zugang?
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Wird gesendet …" : "Anfragen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/pages/AdminPage.tsx
Normal file
32
frontend/src/pages/AdminPage.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import AdminTab from "../components/AdminTab";
|
||||||
|
import ApprovalsTab from "../components/ApprovalsTab";
|
||||||
|
import AccessQueue from "../components/AccessQueue";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const { me } = useAuth();
|
||||||
|
if (!me) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Verwaltung</h2>
|
||||||
|
<p>Bitte melden Sie sich an, um die Verwaltung zu nutzen.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (me.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Verwaltung</h2>
|
||||||
|
<p>Nur für Administratoren. Ihr Konto hat die Rolle „{me.role}“.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Verwaltung</h2>
|
||||||
|
<AccessQueue />
|
||||||
|
<ApprovalsTab />
|
||||||
|
<AdminTab />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
frontend/src/pages/LoginPage.tsx
Normal file
44
frontend/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, Navigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { ApiError, api } from "../api";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const token = params.get("token") ?? "";
|
||||||
|
const { me, refresh } = useAuth();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(!!token);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
setBusy(true);
|
||||||
|
api
|
||||||
|
.consumeMagic(token)
|
||||||
|
.then(() => refresh())
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
setError(
|
||||||
|
e instanceof ApiError
|
||||||
|
? "Dieser Link ist ungültig, abgelaufen oder wurde bereits verwendet."
|
||||||
|
: String(e),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}, [token, refresh]);
|
||||||
|
|
||||||
|
if (me) return <Navigate to="/app/workflows" replace />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Login</h2>
|
||||||
|
{busy && <p>Link wird geprüft …</p>}
|
||||||
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
|
{!token && !error && (
|
||||||
|
<p>
|
||||||
|
Kein Link vorhanden. <Link to="/access">Zugang anfragen</Link> — der
|
||||||
|
Login-Link kommt per E-Mail.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/pages/StartPage.tsx
Normal file
26
frontend/src/pages/StartPage.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
|
export default function StartPage() {
|
||||||
|
const { me } = useAuth();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Willkommen</h2>
|
||||||
|
<p>
|
||||||
|
Kirche Einfach unterstützt die Verwaltung Ihrer Gemeinde: Vorgänge
|
||||||
|
starten, Freigaben erteilen, Zugänge verwalten.
|
||||||
|
</p>
|
||||||
|
{me ? (
|
||||||
|
<p>
|
||||||
|
Angemeldet als <strong>{me.email}</strong> ({me.tenant_id}). Weiter zu{" "}
|
||||||
|
<Link to="/app/workflows">Workflows</Link>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
<Link to="/access">Zugang anfragen</Link>, um loszulegen. Mit
|
||||||
|
Einladungslink geht es direkt zum <Link to="/login">Login</Link>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/pages/TasksPage.tsx
Normal file
45
frontend/src/pages/TasksPage.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ApiError, api, type Run } from "../api";
|
||||||
|
import ApprovalsTab from "../components/ApprovalsTab";
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
const [runs, setRuns] = useState<{ flow: string; run: Run }[]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const flows = await api.listFlows();
|
||||||
|
const all: { flow: string; run: Run }[] = [];
|
||||||
|
for (const f of flows) {
|
||||||
|
const rs = await api.listRuns(f.id);
|
||||||
|
for (const r of rs) all.push({ flow: f.name, run: r });
|
||||||
|
}
|
||||||
|
all.sort((a, b) => (a.run.created_at < b.run.created_at ? 1 : -1));
|
||||||
|
setRuns(all);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Aufgaben</h2>
|
||||||
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
|
<h3>Eigene Läufe</h3>
|
||||||
|
{runs.length === 0 && <p>Noch keine Läufe.</p>}
|
||||||
|
<ul>
|
||||||
|
{runs.map(({ flow, run }) => (
|
||||||
|
<li key={run.id}>
|
||||||
|
<strong>{run.status}</strong> · {flow} ·{" "}
|
||||||
|
<code>{run.output?.result ?? run.output?.error ?? "–"}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<ApprovalsTab />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/pages/WorkflowsPage.tsx
Normal file
137
frontend/src/pages/WorkflowsPage.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Form from "@rjsf/core";
|
||||||
|
import validator from "@rjsf/validator-ajv8";
|
||||||
|
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||||
|
import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
|
|
||||||
|
export default function WorkflowsPage() {
|
||||||
|
const { me } = useAuth();
|
||||||
|
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
||||||
|
const [flowId, setFlowId] = useState("");
|
||||||
|
const [schema, setSchema] = useState<InputSchema | null>(null);
|
||||||
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||||
|
const [freeText, setFreeText] = useState("");
|
||||||
|
const [runs, setRuns] = useState<Run[]>([]);
|
||||||
|
const [result, setResult] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const fail = useCallback((e: unknown) => {
|
||||||
|
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.listFlows()
|
||||||
|
.then((list) => {
|
||||||
|
setFlows(list);
|
||||||
|
const first = list[0];
|
||||||
|
if (first) setFlowId((prev) => prev || first.id);
|
||||||
|
})
|
||||||
|
.catch(fail);
|
||||||
|
}, [fail]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!flowId) return;
|
||||||
|
setError("");
|
||||||
|
setResult("");
|
||||||
|
Promise.all([api.getInputSchema(flowId), api.listRuns(flowId)])
|
||||||
|
.then(([s, r]) => {
|
||||||
|
setSchema(s);
|
||||||
|
setFormData({});
|
||||||
|
setRuns(r);
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
// Flows without a deployed version have no schema yet.
|
||||||
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
|
setSchema(null);
|
||||||
|
setRuns([]);
|
||||||
|
setError("Für diesen Flow ist noch keine Version deployed.");
|
||||||
|
} else fail(e);
|
||||||
|
});
|
||||||
|
}, [flowId, fail]);
|
||||||
|
|
||||||
|
async function onRun(input: unknown) {
|
||||||
|
if (!flowId) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setResult("");
|
||||||
|
try {
|
||||||
|
const run = await api.runFlow(flowId, input);
|
||||||
|
setRuns((prev) => [run, ...prev]);
|
||||||
|
setResult(run.output?.result ?? run.output?.error ?? run.status);
|
||||||
|
} catch (e) {
|
||||||
|
fail(e);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Workflows</h2>
|
||||||
|
{!me && <p>Sie sind nicht angemeldet — Läufe nutzen den Standard-Mandanten.</p>}
|
||||||
|
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||||
|
<select value={flowId} onChange={(e) => setFlowId(e.target.value)}>
|
||||||
|
<option value="">— Flow wählen —</option>
|
||||||
|
{flows.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{schema && schema.fallback && (
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
rows={6}
|
||||||
|
style={{ width: "100%", fontFamily: "monospace" }}
|
||||||
|
value={freeText}
|
||||||
|
onChange={(e) => setFreeText(e.target.value)}
|
||||||
|
placeholder="Freitext-Eingabe"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => void onRun({ text: freeText })}
|
||||||
|
disabled={!flowId || busy}
|
||||||
|
>
|
||||||
|
{busy ? "Läuft …" : "Starten"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schema && !schema.fallback && (
|
||||||
|
<Form
|
||||||
|
schema={schema.json_schema as RJSFSchema}
|
||||||
|
uiSchema={schema.ui_schema as UiSchema}
|
||||||
|
validator={validator}
|
||||||
|
formData={formData}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((e.formData as Record<string, unknown>) ?? {})
|
||||||
|
}
|
||||||
|
onSubmit={(e) => void onRun(e.formData)}
|
||||||
|
>
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "Läuft …" : "Starten"}
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<div>
|
||||||
|
<h3>Ergebnis</h3>
|
||||||
|
<pre style={{ whiteSpace: "pre-wrap" }}>{result}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3>Letzte Läufe</h3>
|
||||||
|
{runs.length === 0 && <p>Noch keine Läufe.</p>}
|
||||||
|
<ul>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<strong>{r.status}</strong> · {r.tenant_id} ·{" "}
|
||||||
|
<code>{r.output?.result ?? r.output?.error ?? "–"}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue