feat: router with RJSF workflows and access management UI

This commit is contained in:
Jonas Miederer 2026-09-12 07:36:05 +02:00
parent 46ecab00db
commit 62d01b5097
15 changed files with 948 additions and 144 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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<Tab>("flows");
async function onLogout() {
await logout();
navigate("/");
}
return (
<div style={{ maxWidth: 900, margin: "0 auto", padding: 16 }}>
<h1>Kirche Einfach</h1>
<p>KI-gestützte Verwaltungsplattform für Kirche</p>
<nav style={{ display: "flex", gap: 12, marginBottom: 16 }}>
<button style={tabStyle(tab === "flows")} onClick={() => setTab("flows")}>
Vorgänge
</button>
<button
style={tabStyle(tab === "approvals")}
onClick={() => setTab("approvals")}
>
Freigaben
</button>
<button style={tabStyle(tab === "admin")} onClick={() => setTab("admin")}>
Administration
</button>
<nav style={navStyle}>
<Link to="/">Start</Link>
{!me && <Link to="/access">Zugang</Link>}
{me && (
<>
<Link to="/app/workflows">Workflows</Link>
<Link to="/app/tasks">Aufgaben</Link>
<Link to="/app/admin">Verwaltung</Link>
<span style={{ marginLeft: "auto" }}>{me.email}</span>
<button onClick={() => void onLogout()}>Abmelden</button>
</>
)}
</nav>
{tab === "flows" && <FlowsTab />}
{tab === "approvals" && <ApprovalsTab />}
{tab === "admin" && <AdminTab />}
{loading ? (
<p>Wird geladen …</p>
) : (
<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>
);
}
export default function App() {
return (
<AuthProvider>
<Shell />
</AuthProvider>
);
}

View file

@ -69,6 +69,33 @@ export interface SyncReport {
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 {
status: number;
constructor(status: number, message: string) {
@ -79,6 +106,8 @@ export class ApiError extends Error {
async function req<T>(path: string, init?: RequestInit): Promise<T> {
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<FlowVersion>(`/api/flows/${flowId}/versions/${versionNo}/deploy`, {
method: "POST",
}),
runFlow: (flowId: string, input: unknown, tenantId: string) =>
runFlow: (flowId: string, input: unknown) =>
req<Run>(`/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<InputSchema>(`/api/flows/${flowId}/input-schema`),
listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`),
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
decide: (
@ -148,4 +180,33 @@ export const api = {
req<SyncReport>("/api/flows/sync", {
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
View 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);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -1,9 +1,12 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}