refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter

This commit is contained in:
Jonas Miederer 2026-09-28 18:03:07 +02:00
parent 06ab25fb47
commit 03af41e837
74 changed files with 6089 additions and 5183 deletions

View file

@ -9,20 +9,28 @@
"version": "0.1.0",
"dependencies": {
"@mantine/core": "^9.6.1",
"@mantine/dates": "^9.6.1",
"@mantine/form": "^9.6.1",
"@mantine/hooks": "^9.6.1",
"@mantine/notifications": "^9.6.1",
"@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0",
"@tanstack/react-query": "^5.62.0",
"dayjs": "^1.11.13",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.18.3"
"react-router-dom": "^7.18.3",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
"sharp": "^0.34.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}
@ -261,6 +269,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@ -309,6 +326,17 @@
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -804,6 +832,496 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -873,22 +1391,35 @@
}
},
"node_modules/@mantine/dates": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.6.1.tgz",
"integrity": "sha512-3NxfqrcwMuYB9wvxIhGhEhhazK3wcA2wG4AuNVFGJDd97ujWWAy+jGHqBF9jOusp/Mk4bn8u+3SuDICtLPDf5w==",
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.6.3.tgz",
"integrity": "sha512-sPDlPBKl0R8VR9mmTVaNvuvsczD79qMbZOpe+wjkQkrvE102G+iwk2cxW8UIA+o8jqc6jAP4Cf2aoy9EqJ/hiQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"@mantine/core": "9.6.1",
"@mantine/hooks": "9.6.1",
"@mantine/core": "9.6.3",
"@mantine/hooks": "9.6.3",
"dayjs": ">=1.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/form": {
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/@mantine/form/-/form-9.6.3.tgz",
"integrity": "sha512-u62dq7iwIPc5hs7hZ00sgAjlhsCgGs88J2NeCfUF7Ap4+65FoR1xi572xiIfLfzugFbDvORKFQir9WGfFJ00ew==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-deep-equal": "^3.1.3",
"klona": "^2.0.6"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/@mantine/hooks": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.1.tgz",
@ -898,6 +1429,31 @@
"react": "^19.2.0"
}
},
"node_modules/@mantine/notifications": {
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.6.3.tgz",
"integrity": "sha512-1imtNfBwZ0JBDrg3ohVuZLnJvwRVhb7eWFbcfz5QFSy7hbM/MereURExWUGbZRBiJhQz850Aj6fVSEAviK6vdg==",
"license": "MIT",
"dependencies": {
"@mantine/store": "9.6.3",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
"@mantine/core": "9.6.3",
"@mantine/hooks": "9.6.3",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}
},
"node_modules/@mantine/store": {
"version": "9.6.3",
"resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.6.3.tgz",
"integrity": "sha512-EdD0QAn5HpsAKbvZwz7cqzq4E+4UpXCWK3hK5SR/O/WFtSAm0tUIyKBcrQ9baPRoQknfhOBmN7A17K+PdOzGqg==",
"license": "MIT",
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@ -952,13 +1508,12 @@
}
},
"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==",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.1.tgz",
"integrity": "sha512-dlbQUCoTl/+/TenyYs1zAKXf9sLTebtY+6pB+bUFsaN4xkdAtR8YBLwmpa1N5yNiRzf3fbxveISNuZmDrZE/yQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@x0k/json-schema-merge": "^1.0.3",
"@x0k/json-schema-merge": "^1.0.6",
"fast-equals": "^6.0.0",
"fast-uri": "^4.1.4",
"jsonpointer": "^5.0.1",
@ -1344,6 +1899,12 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@tabler/icons": {
"version": "3.46.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
@ -1370,6 +1931,32 @@
"react": ">= 16"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.104.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.0.tgz",
"integrity": "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.104.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.0.tgz",
"integrity": "sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.104.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1426,14 +2013,13 @@
"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
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@ -1471,11 +2057,10 @@
}
},
"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==",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.6.tgz",
"integrity": "sha512-SSdWAhirRj9UGTSjmgMnchC3Nytpbde46qPEXVQLm/S3opteW6E/VX4UzLlfmA9L2zdkmG3c69sfLfHbT0jl0w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.15"
}
@ -1630,7 +2215,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/dayjs": {
@ -1657,12 +2241,32 @@
}
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
@ -1729,19 +2333,18 @@
"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==",
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.4.tgz",
"integrity": "sha512-JiBeR0k76FIahi9zAtQR7X1jgZDKLE+fs5S9ryjOGYZWZCOcm8QiwgeahkFvaboS3i6PkGBopRznHKuKhm9zpg==",
"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==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
"integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
"funding": [
{
"type": "github",
@ -1752,8 +2355,7 @@
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/fdir": {
"version": "6.5.0",
@ -1811,7 +2413,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@ -1851,11 +2452,31 @@
"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/klona": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz",
"integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -1930,6 +2551,15 @@
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2008,6 +2638,23 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@ -2033,8 +2680,7 @@
"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
"license": "MIT"
},
"node_modules/react-number-format": {
"version": "5.4.5",
@ -2163,6 +2809,22 @@
}
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -2240,6 +2902,64 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/sharp/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2475,6 +3195,15 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -12,20 +12,28 @@
},
"dependencies": {
"@mantine/core": "^9.6.1",
"@mantine/dates": "^9.6.1",
"@mantine/form": "^9.6.1",
"@mantine/hooks": "^9.6.1",
"@mantine/notifications": "^9.6.1",
"@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0",
"@tanstack/react-query": "^5.62.0",
"dayjs": "^1.11.13",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.18.3"
"react-router-dom": "^7.18.3",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0",
"sharp": "^0.34.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
}

View file

@ -16,5 +16,4 @@ Dutch-language documents.
| hero-church.webp | Stone church tower, blue sky | https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/ |
| church-pews.webp | Bright empty pews, checkerboard| https://www.pexels.com/photo/church-pews-7566207/ |
| paperwork-desk.webp | Desk with stamps and forms | https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/ |
| team-laptop.webp | Team collaborating at laptop | https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/ |
| pilot-meeting.webp | Consultation meeting, notes | https://www.pexels.com/photo/two-people-shaking-hands-5668858/ |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View file

@ -50,12 +50,12 @@ try {
const page = await context.newPage();
for (const shot of shots) {
await page.goto(base + shot.path, { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
await page.waitForSelector("main, #root", { timeout: 15000 });
if (shot.selectFlow) {
// Show the generated RJSF form instead of the default first flow.
await page.locator('[role="combobox"]').first().click();
await page.getByRole("option", { name: shot.selectFlow }).click();
await page.waitForTimeout(2000);
await page.waitForSelector("form", { timeout: 15000 });
}
const file = join(outDir, `${shot.name}-${scheme}.png`);
await page.screenshot({ path: file, fullPage: shot.fullPage });

View file

@ -46,10 +46,6 @@ PHOTOS = {
pexels(18687845),
"https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/",
),
"team-laptop.webp": (
pexels(7793702),
"https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/",
),
"pilot-meeting.webp": (
pexels(5668858),
"https://www.pexels.com/photo/two-people-shaking-hands-5668858/",

View file

@ -1,22 +1,21 @@
/* Generate favicon PNGs + manifest icons from favicon.svg (re-runnable).
*
* Usage (frontend container, Chromium already installed for screenshots):
* Usage (frontend container):
* npm run icons
*
* Renders the SVG at each target size (crisp native rasterization) into
* public/. apple-touch-icon keeps the solid kirchenblau background (iOS
* fills transparency black). The 512 icon doubles as maskable: the glyph
* stays inside the inner 80% safe zone.
* Renders the SVG at each target size with sharp (no browser needed).
* apple-touch-icon keeps the solid kirchenblau background (iOS fills
* transparency black). The 512 icon doubles as maskable: the glyph stays
* inside the inner 80% safe zone.
*/
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
import sharp from "sharp";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const svg = readFileSync(join(root, "public", "favicon.svg"), "utf-8");
const dataUrl = `data:image/svg+xml,${encodeURIComponent(svg)}`;
const svg = readFileSync(join(root, "public", "favicon.svg"));
const targets = [
["favicon-32x32.png", 32],
@ -25,20 +24,11 @@ const targets = [
["icon-512.png", 512],
];
const browser = await chromium.launch();
try {
const page = await browser.newPage();
for (const [file, size] of targets) {
await page.setViewportSize({ width: size, height: size });
await page.setContent(
`<body style="margin:0"><img src="${dataUrl}" width="${size}" height="${size}"/></body>`,
);
await page.locator("img").screenshot({
path: join(root, "public", file),
omitBackground: true,
});
console.log(`wrote public/${file}`);
}
} finally {
await browser.close();
for (const [file, size] of targets) {
await sharp(svg, { density: 300 })
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
.flatten({ background: file === "apple-touch-icon.png" ? "#1d4a79" : "#ffffff" })
.png()
.toFile(join(root, "public", file));
console.log(`wrote public/${file}`);
}

View file

@ -10,6 +10,8 @@ import {
Text,
Title,
} from "@mantine/core";
import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { AuthProvider, useAuth } from "./auth";
import SchemeToggle from "./components/SchemeToggle";
@ -67,15 +69,17 @@ function Nav() {
);
}
function Loading() {
return (
<Center mt="xl">
<Loader />
</Center>
);
}
function RequireAuth({ children }: { children: ReactNode }) {
const { me, loading } = useAuth();
if (loading) {
return (
<Center mt="xl">
<Loader />
</Center>
);
}
if (loading) return <Loading />;
// Runs, run history and approvals are login-gated server-side;
// anonymous visitors go to the magic-link login.
if (!me) return <Navigate to="/login" replace />;
@ -111,9 +115,7 @@ function Shell() {
KI-gestützte Verwaltungsplattform für Kirche
</Text>
{loading ? (
<Center mt="xl">
<Loader />
</Center>
<Loading />
) : (
<Routes>
<Route path="/" element={<StartPage />} />
@ -143,7 +145,7 @@ function Shell() {
</RequireAuth>
}
/>
<Route path="*" element={<StartPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)}
</Container>
@ -152,10 +154,17 @@ function Shell() {
);
}
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 15_000, retry: 1 } },
});
export default function App() {
return (
<AuthProvider>
<Shell />
</AuthProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Notifications position="top-right" />
<Shell />
</AuthProvider>
</QueryClientProvider>
);
}

View file

@ -61,6 +61,11 @@ export interface Decision {
continuation_run: Run | null;
}
export interface NamedRun {
flow_name: string;
run: Run;
}
export interface SyncReport {
added: string[];
updated: string[];
@ -153,6 +158,7 @@ export const api = {
getInputSchema: (flowId: string) =>
req<InputSchema>(`/api/flows/${flowId}/input-schema`),
listRuns: (flowId: string) => req<Run[]>(`/api/flows/${flowId}/runs`),
listAllRuns: () => req<NamedRun[]>("/api/runs"),
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
decide: (taskId: string, decision: "approve" | "reject", note: string) =>
req<Decision>(`/api/approvals/${taskId}/decision`, {

View file

@ -1,52 +1,55 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import {
IconAlertCircle,
IconCheck,
} from "@tabler/icons-react";
import { ApiError, api, type AccessRequest } from "../api";
import { notifications } from "@mantine/notifications";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type AccessRequest } from "../api";
import { formatApiError } from "../utils";
import { DecisionButtons } from "./DecisionButtons";
import { ErrorAlert } from "./ErrorAlert";
import { FormattedDate } from "./FormattedDate";
import StatusBadge from "./StatusBadge";
export default function AccessQueue() {
const [requests, setRequests] = useState<AccessRequest[]>([]);
const [error, setError] = useState("");
const [info, setInfo] = useState("");
const queryClient = useQueryClient();
const [busyId, setBusyId] = useState("");
const [error, setError] = useState("");
const load = useCallback(async () => {
try {
setRequests(await api.listAccessRequests());
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
}
}, []);
const requestsQuery = useQuery({
queryKey: ["access-requests"],
queryFn: () => api.listAccessRequests(),
});
const requests: AccessRequest[] = requestsQuery.data ?? [];
useEffect(() => {
void load();
}, [load]);
if (requestsQuery.error) setError(formatApiError(requestsQuery.error));
}, [requestsQuery.error]);
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 — die E-Mail kam nicht an (SMTP prüfen).");
notifications.show({
title: "Genehmigt",
message: "Die E-Mail kam nicht an (SMTP prüfen).",
color: "yellow",
});
} else {
notifications.show({
title: approve ? "Genehmigt" : "Abgelehnt",
message: approve ? "Zugang freigegeben." : "Anfrage abgelehnt.",
color: approve ? "green" : "red",
});
}
await queryClient.invalidateQueries({ queryKey: ["access-requests"] });
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
setError(formatApiError(e));
} finally {
setBusyId("");
}
@ -60,16 +63,7 @@ export default function AccessQueue() {
<Title order={3} mb="xs">
Zugangsanfragen
</Title>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
{info && (
<Alert color="green" icon={<IconCheck size={18} />} mb="md">
{info}
</Alert>
)}
<ErrorAlert message={error} />
{pending.length === 0 && (
<Text c="dimmed" mb="md">
Keine offenen Anfragen.
@ -90,23 +84,13 @@ export default function AccessQueue() {
{r.reason}
</Text>
)}
<Group>
<Button
color="green"
loading={busyId === r.id}
onClick={() => void decide(r.id, true)}
>
Genehmigen
</Button>
<Button
color="red"
variant="light"
loading={busyId === r.id}
onClick={() => void decide(r.id, false)}
>
Ablehnen
</Button>
</Group>
<DecisionButtons
busy={busyId === r.id}
onApprove={() => void decide(r.id, true)}
onReject={() => void decide(r.id, false)}
approveLabel="Genehmigen"
rejectLabel="Ablehnen"
/>
</Card>
))}
</Stack>
@ -119,7 +103,7 @@ export default function AccessQueue() {
<Group key={r.id} gap="xs" mb={4}>
<StatusBadge status={r.status} />
<Text size="sm">
{r.name} · {r.email}
{r.name} · {r.email} · <FormattedDate iso={r.decided_at} />
</Text>
</Group>
))}

View file

@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import {
Alert,
Anchor,
Button,
Card,
@ -10,83 +9,111 @@ import {
TextInput,
Title,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { notifications } from "@mantine/notifications";
import {
IconAlertCircle,
IconCheck,
IconExternalLink,
IconRefresh,
IconUpload,
} from "@tabler/icons-react";
import { z } from "zod";
import {
ApiError,
LANGFLOW_URL,
api,
type FlowDefinition,
type FlowVersion,
} from "../api";
import { formatApiError } from "../utils";
import FlowHelp from "./FlowHelp";
import { ErrorAlert } from "./ErrorAlert";
import StatusBadge from "./StatusBadge";
const importSchema = z.object({
langflowId: z.string().min(1, "Flow-ID erforderlich"),
importName: z.string(),
});
export default function AdminTab() {
const [flows, setFlows] = useState<FlowDefinition[]>([]);
const [versions, setVersions] = useState<Record<string, FlowVersion[]>>({});
const [langflowId, setLangflowId] = useState("");
const [importName, setImportName] = useState("");
const importForm = useForm({
initialValues: { langflowId: "", importName: "" },
validate: (values) => {
const parsed = importSchema.safeParse(values);
if (parsed.success) return {};
return {
langflowId:
parsed.error.flatten().fieldErrors.langflowId?.[0] ?? null,
};
},
});
const [error, setError] = useState("");
const [info, setInfo] = useState("");
const load = useCallback(async () => {
function notifyInfo(message: string) {
notifications.show({ message, color: "green" });
}
useEffect(() => {
void (async () => {
try {
const list = await api.listFlows();
setFlows(list);
const entries = await Promise.all(
list.map(async (f) => [f.id, await api.listVersions(f.id)] as const),
);
setVersions(Object.fromEntries(entries));
} catch (e) {
setError(formatApiError(e));
}
})();
}, []);
async function load() {
try {
const list = await api.listFlows();
setFlows(list);
const all: Record<string, FlowVersion[]> = {};
for (const f of list) {
all[f.id] = await api.listVersions(f.id);
}
setVersions(all);
const entries = await Promise.all(
list.map(async (f) => [f.id, await api.listVersions(f.id)] as const),
);
setVersions(Object.fromEntries(entries));
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
setError(formatApiError(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
}
async function onImport() {
setError("");
setInfo("");
const validation = importForm.validate();
if (validation.hasErrors) return;
const { langflowId, importName } = importForm.values;
try {
const result = await api.importFlow(
langflowId,
importName.trim() ? importName.trim() : undefined,
);
setInfo(
notifyInfo(
`Importiert: ${result.definition.name} (Version ${result.version.version_no})`,
);
setLangflowId("");
setImportName("");
importForm.reset();
await load();
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
setError(formatApiError(e));
}
}
async function onDeploy(flowId: string, versionNo: number) {
setError("");
setInfo("");
try {
const version = await api.deployVersion(flowId, versionNo);
setInfo(`Deployed: Version ${version.version_no} (${version.lfx_flow_id})`);
notifyInfo(`Deployed: Version ${version.version_no} (${version.lfx_flow_id})`);
await load();
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
setError(formatApiError(e));
}
}
async function onSync() {
setError("");
setInfo("");
try {
const r = await api.syncFlows();
const parts = [
@ -98,10 +125,10 @@ export default function AdminTab() {
const errs = Object.entries(r.errors)
.map(([f, e]) => `${f}: ${e}`)
.join("; ");
setInfo(`Sync ${parts}${errs ? ` — Fehler: ${errs}` : ""}`);
notifyInfo(`Sync ${parts}${errs ? ` — Fehler: ${errs}` : ""}`);
await load();
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
setError(formatApiError(e));
}
}
@ -110,16 +137,7 @@ export default function AdminTab() {
<Title order={3} mb="xs">
Flows & Versionen
</Title>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
{info && (
<Alert color="green" icon={<IconCheck size={18} />} mb="md">
{info}
</Alert>
)}
<ErrorAlert message={error} />
<Card withBorder padding="md" mb="md">
<Text mb="xs">
Flows werden visuell in Langflow entworfen:{" "}
@ -130,20 +148,18 @@ export default function AdminTab() {
<Group align="flex-end">
<TextInput
label="Langflow Flow-ID (UUID)"
value={langflowId}
onChange={(e) => setLangflowId(e.target.value)}
style={{ flex: 1 }}
styles={{ input: { fontFamily: "monospace" } }}
{...importForm.getInputProps("langflowId")}
/>
<TextInput
label="Name (optional)"
value={importName}
onChange={(e) => setImportName(e.target.value)}
{...importForm.getInputProps("importName")}
/>
<Button
leftSection={<IconUpload size={16} />}
onClick={() => void onImport()}
disabled={!langflowId.trim()}
disabled={!importForm.values.langflowId.trim()}
>
Importieren + Deployen
</Button>
@ -200,9 +216,9 @@ export default function AdminTab() {
</Table.Tr>
);
}
const live = [...list]
.reverse()
.find((v) => v.deployed_at && v.id !== latest.id);
const live = list
.filter((v) => v.deployed_at && v.id !== latest.id)
.at(-1);
return (
<Table.Tr key={latest.id}>
<Table.Td>

View file

@ -1,49 +1,55 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import {
Alert,
Button,
Card,
Code,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react";
import { ApiError, api, type ApprovalTask } from "../api";
import { notifications } from "@mantine/notifications";
import { IconRefresh } from "@tabler/icons-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type ApprovalTask } from "../api";
import { useAuth } from "../auth";
import { formatApiError } from "../utils";
import { DecisionButtons } from "./DecisionButtons";
import { ErrorAlert } from "./ErrorAlert";
import { JsonView } from "./JsonView";
import { FormattedDate } from "./FormattedDate";
import StatusBadge from "./StatusBadge";
export default function ApprovalsTab() {
const { me } = useAuth();
const [tasks, setTasks] = useState<ApprovalTask[]>([]);
const queryClient = useQueryClient();
const [note, setNote] = useState("");
const [error, setError] = useState("");
const [busyId, setBusyId] = useState("");
const load = useCallback(async () => {
try {
setTasks(await api.listApprovals());
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
}
}, []);
const tasksQuery = useQuery({
queryKey: ["approvals"],
queryFn: api.listApprovals,
});
const tasks: ApprovalTask[] = tasksQuery.data ?? [];
useEffect(() => {
void load();
}, [load]);
if (tasksQuery.error) setError(formatApiError(tasksQuery.error));
}, [tasksQuery.error]);
async function decide(taskId: string, decision: "approve" | "reject") {
setBusyId(taskId);
setError("");
try {
const result = await api.decide(taskId, decision, note);
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? result.task : t)),
);
await api.decide(taskId, decision, note);
notifications.show({
title: decision === "approve" ? "Freigegeben" : "Abgelehnt",
color: decision === "approve" ? "green" : "red",
message: note || undefined,
});
await queryClient.invalidateQueries({ queryKey: ["approvals"] });
await queryClient.invalidateQueries({ queryKey: ["all-runs"] });
} catch (e) {
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
setError(formatApiError(e));
} finally {
setBusyId("");
}
@ -57,11 +63,7 @@ export default function ApprovalsTab() {
<Title order={3} mb="xs">
Freigaben
</Title>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
<ErrorAlert message={error} />
<Text size="sm" c="dimmed" mb="md">
Angemeldet als {me?.email ?? "–"} — Entscheidungen werden diesem
Konto zugeordnet.
@ -76,7 +78,10 @@ export default function ApprovalsTab() {
<Button
variant="light"
leftSection={<IconRefresh size={16} />}
onClick={() => void load()}
loading={tasksQuery.isFetching}
onClick={() =>
void queryClient.invalidateQueries({ queryKey: ["approvals"] })
}
>
Aktualisieren
</Button>
@ -99,27 +104,15 @@ export default function ApprovalsTab() {
{t.tenant_id}
</Text>
</Group>
<Code block mb="sm">
{JSON.stringify(t.payload)}
</Code>
<JsonView value={t.payload} />
<Group>
<Button
color="green"
leftSection={<IconCheck size={16} />}
loading={busyId === t.id}
onClick={() => void decide(t.id, "approve")}
>
Freigeben
</Button>
<Button
color="red"
variant="light"
leftSection={<IconX size={16} />}
loading={busyId === t.id}
onClick={() => void decide(t.id, "reject")}
>
Ablehnen
</Button>
<DecisionButtons
busy={busyId === t.id}
onApprove={() => void decide(t.id, "approve")}
onReject={() => void decide(t.id, "reject")}
approveLabel="Freigeben"
rejectLabel="Ablehnen"
/>
</Group>
</Card>
))}
@ -132,7 +125,8 @@ export default function ApprovalsTab() {
<StatusBadge status={t.status} />
<Text size="sm">
{t.title || t.id} · {t.approver ?? "–"}
{t.decision_note ? ` · „${t.decision_note}“` : ""}
{t.decision_note ? ` · „${t.decision_note}“` : ""} ·{" "}
<FormattedDate iso={t.decided_at} />
</Text>
</Group>
))}

View file

@ -0,0 +1,38 @@
import { Button, Group } from "@mantine/core";
import { IconCheck, IconX } from "@tabler/icons-react";
export function DecisionButtons({
busy,
onApprove,
onReject,
approveLabel,
rejectLabel,
}: {
busy: boolean;
onApprove: () => void;
onReject: () => void;
approveLabel: string;
rejectLabel: string;
}) {
return (
<Group>
<Button
color="green"
leftSection={<IconCheck size={16} />}
loading={busy}
onClick={onApprove}
>
{approveLabel}
</Button>
<Button
color="red"
variant="light"
leftSection={<IconX size={16} />}
loading={busy}
onClick={onReject}
>
{rejectLabel}
</Button>
</Group>
);
}

View file

@ -0,0 +1,10 @@
import dayjs from "dayjs";
import "dayjs/locale/de";
dayjs.locale("de");
/** Localized date (was toLocaleString in one place, missing elsewhere). */
export function FormattedDate({ iso }: { iso: string | null }) {
if (!iso) return <>–</>;
return <>{dayjs(iso).format("DD.MM.YYYY HH:mm")}</>;
}

View file

@ -0,0 +1,12 @@
import { Code } from "@mantine/core";
const MAX_LEN = 2000;
/** Pretty JSON view (was raw JSON.stringify / [object Object] in 3 places). */
export function JsonView({ value }: { value: unknown }) {
if (value === null || value === undefined) return <>–</>;
const text =
typeof value === "string" ? value : JSON.stringify(value, null, 2);
const shown = text.length > MAX_LEN ? text.slice(0, MAX_LEN) + "…" : text;
return <Code block>{shown}</Code>;
}

View file

@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { Card, Image, Text, ThemeIcon, Title } from "@mantine/core";
export const PHOTO_CLASS = "photo";
/** Shared landing building blocks (was copy-pasted Card/Icon/Title/Text x10). */
export function SectionTitle({ children }: { children: string }) {
return (
<Title order={2} mb="xs" ta="center">
{children}
</Title>
);
}
export function SectionCard({ children }: { children: ReactNode }) {
return (
<Card withBorder padding="xl" radius="lg">
{children}
</Card>
);
}
export function FeatureCard({
icon,
title,
text,
iconSize = 40,
}: {
icon: ReactNode;
title: string;
text: string;
iconSize?: number;
}) {
return (
<Card withBorder padding="lg" radius="lg">
<ThemeIcon size={iconSize} radius="md" variant="light" mb="sm">
{icon}
</ThemeIcon>
<Title order={4} mb="xs">
{title}
</Title>
<Text size="sm" c="dimmed">
{text}
</Text>
</Card>
);
}
export function PhotoImage({
src,
alt,
h,
eager = false,
}: {
src: string;
alt: string;
h?: number;
eager?: boolean;
}) {
return (
<Image
src={src}
alt={alt}
radius="lg"
className={PHOTO_CLASS}
loading={eager ? undefined : "lazy"}
h={h}
fit="cover"
/>
);
}

View file

@ -1,5 +1,7 @@
import { Code, Table } from "@mantine/core";
import { Table } from "@mantine/core";
import StatusBadge from "./StatusBadge";
import { FormattedDate } from "./FormattedDate";
import { JsonView } from "./JsonView";
import type { Run } from "../api";
export function RunsTable({ runs }: { runs: Run[] }) {
@ -19,9 +21,11 @@ export function RunsTable({ runs }: { runs: Run[] }) {
<StatusBadge status={r.status} />
</Table.Td>
<Table.Td>
<Code>{r.output?.result ?? r.output?.error ?? "–"}</Code>
<JsonView value={r.output?.result ?? r.output?.error ?? "–"} />
</Table.Td>
<Table.Td>
<FormattedDate iso={r.created_at} />
</Table.Td>
<Table.Td>{new Date(r.created_at).toLocaleString("de-DE")}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>

View file

@ -1,7 +1,6 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Alert,
Button,
Card,
Stack,
@ -10,31 +9,53 @@ import {
TextInput,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconCheck } from "@tabler/icons-react";
import { useForm } from "@mantine/form";
import { z } from "zod";
import { ApiError, api } from "../api";
import { formatApiError } from "../utils";
import { ErrorAlert } from "../components/ErrorAlert";
const accessSchema = z.object({
name: z.string().min(1, "Name erforderlich").max(200),
email: z.string().email("Gültige E-Mail erforderlich").max(320),
reason: z.string().max(2000),
});
export default function AccessPage() {
const [params] = useSearchParams();
const [name, setName] = useState("");
const [email, setEmail] = useState(params.get("email") ?? "");
const [reason, setReason] = useState("");
const form = useForm({
initialValues: {
name: "",
email: params.get("email") ?? "",
reason: "",
},
validate: (values) => {
const parsed = accessSchema.safeParse(values);
if (parsed.success) return {};
const flat = parsed.error.flatten().fieldErrors;
return {
name: flat.name?.[0] ?? null,
email: flat.email?.[0] ?? null,
reason: flat.reason?.[0] ?? null,
};
},
});
const [done, setDone] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
async function onSubmit(values: typeof form.values) {
setBusy(true);
setError("");
try {
await api.requestAccess(name.trim(), email.trim(), reason.trim());
await api.requestAccess(values.name.trim(), values.email.trim(), values.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}`
: formatApiError(err)
: String(err),
);
} finally {
@ -48,38 +69,25 @@ export default function AccessPage() {
Zugang anfragen
</Title>
{done ? (
<Alert color="green" icon={<IconCheck size={18} />} title="Angefragt">
Danke! Ihre Anfrage ist eingegangen. Sie erhalten eine E-Mail, sobald
sie freigegeben wurde.
</Alert>
<Text c="green">
Danke! Ihre Anfrage ist eingegangen. Sie erhalten eine E-Mail,
sobald sie freigegeben wurde.
</Text>
) : (
<form onSubmit={(e) => void onSubmit(e)}>
<form onSubmit={form.onSubmit((v) => void onSubmit(v))}>
<Stack>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{error}
</Alert>
)}
<TextInput
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={200}
/>
<ErrorAlert message={error} />
<TextInput label="Name" required {...form.getInputProps("name")} />
<TextInput
label="E-Mail"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
maxLength={320}
{...form.getInputProps("email")}
/>
<Textarea
label="Wofür brauchen Sie Zugang?"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={4}
{...form.getInputProps("reason")}
/>
<Button type="submit" loading={busy}>
Anfragen

View file

@ -7,40 +7,29 @@ import { useAuth } from "../auth";
export default function AdminPage() {
const { me } = useAuth();
if (!me) {
return (
<div>
<Title order={2} mb="md">
Verwaltung
</Title>
<Alert color="blue">
Bitte melden Sie sich an, um die Verwaltung zu nutzen.
</Alert>
</div>
);
}
if (me.role !== "admin") {
return (
<div>
<Title order={2} mb="md">
Verwaltung
</Title>
<Alert color="red" icon={<IconAlertCircle size={18} />}>
Nur für Administratoren. Ihr Konto hat die Rolle „{me.role}“.
</Alert>
</div>
);
}
const denied = !me || me.role !== "admin";
return (
<div>
<Title order={2} mb="md">
Verwaltung
</Title>
<Stack gap="xl">
<AccessQueue />
<ApprovalsTab />
<AdminTab />
</Stack>
{denied ? (
<Alert
color={!me ? "blue" : "red"}
icon={me ? <IconAlertCircle size={18} /> : undefined}
>
{!me
? "Bitte melden Sie sich an, um die Verwaltung zu nutzen."
: `Nur für Administratoren. Ihr Konto hat die Rolle „${me.role}“.`}
</Alert>
) : (
<Stack gap="xl">
<AccessQueue />
<ApprovalsTab />
<AdminTab />
</Stack>
)}
</div>
);
}

View file

@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
import {
Alert,
Anchor,
Button,
Card,
@ -12,16 +11,31 @@ import {
TextInput,
Title,
} from "@mantine/core";
import { IconAlertCircle, IconCheck, IconMail } from "@tabler/icons-react";
import { useForm } from "@mantine/form";
import { IconMail } from "@tabler/icons-react";
import { z } from "zod";
import { ApiError, api } from "../api";
import { useAuth } from "../auth";
import { formatApiError } from "../utils";
import { ErrorAlert } from "../components/ErrorAlert";
const loginSchema = z.object({
email: z.string().email("Gültige E-Mail erforderlich").max(320),
});
export default function LoginPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const token = params.get("token") ?? "";
const { me, refresh } = useAuth();
const [email, setEmail] = useState("");
const form = useForm({
initialValues: { email: "" },
validate: (values) => {
const parsed = loginSchema.safeParse(values);
if (parsed.success) return {};
return { email: parsed.error.flatten().fieldErrors.email?.[0] ?? null };
},
});
const [sent, setSent] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(!!token);
@ -45,24 +59,23 @@ export default function LoginPage() {
.finally(() => setBusy(false));
}, [token, refresh]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
async function onSubmit(values: typeof form.values) {
setBusy(true);
setError("");
try {
await api.requestMagicLink(email.trim());
await api.requestMagicLink(values.email.trim());
setSent(true);
} catch (err) {
if (err instanceof ApiError && err.status === 403) {
// Not allowed yet — forward to the access request form.
navigate(`/access?email=${encodeURIComponent(email.trim())}`);
navigate(`/access?email=${encodeURIComponent(values.email.trim())}`);
return;
}
setError(
err instanceof ApiError
? err.status === 429
? "Zu viele Links angefordert — bitte später erneut versuchen."
: `${err.status}: ${err.message}`
: formatApiError(err)
: String(err),
);
} finally {
@ -85,32 +98,22 @@ export default function LoginPage() {
<Loader />
</Center>
)}
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{error}
</Alert>
)}
<ErrorAlert message={error} />
</>
) : sent ? (
<Alert color="green" icon={<IconCheck size={18} />} title="Unterwegs">
<Text c="green">
Falls die Adresse freigegeben ist, kommt der Login-Link per
E-Mail. Bitte auch den Spam-Ordner prüfen.
</Alert>
</Text>
) : (
<form onSubmit={(e) => void onSubmit(e)}>
<form onSubmit={form.onSubmit((v) => void onSubmit(v))}>
<Stack>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />}>
{error}
</Alert>
)}
<ErrorAlert message={error} />
<TextInput
label="E-Mail"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
maxLength={320}
{...form.getInputProps("email")}
/>
<Button
type="submit"

View file

@ -37,6 +37,12 @@ import {
IconWand,
} from "@tabler/icons-react";
import { useAuth } from "../auth";
import {
FeatureCard,
PhotoImage,
SectionCard,
SectionTitle,
} from "../components/Landing";
const CTA_LABEL = "Pilotgespräch vereinbaren";
const CTA_TO = "/access";
@ -259,14 +265,6 @@ const pilotPoints = [
"Persönlicher Ansprechpartner während der gesamten Pilotphase",
];
function SectionTitle({ children }: { children: string }) {
return (
<Title order={2} mb="xs" ta="center">
{children}
</Title>
);
}
export default function StartPage() {
const { me } = useAuth();
return (
@ -318,13 +316,10 @@ export default function StartPage() {
)}
</Box>
<Box style={{ flex: 5 }} w={{ base: "100%", md: "auto" }}>
<Image
<PhotoImage
src="/photos/hero-church.webp"
alt="Steinerner Kirchturm unter blauem Himmel"
radius="lg"
className="photo"
h={420}
fit="cover"
/>
</Box>
</Flex>
@ -354,14 +349,10 @@ export default function StartPage() {
</Stack>
</Box>
<Box style={{ flex: 5 }} w={{ base: "100%", md: "auto" }}>
<Image
<PhotoImage
src="/photos/paperwork-desk.webp"
alt="Schreibtisch mit Stempeln und Formularen — so sieht es heute oft aus"
radius="lg"
className="photo"
loading="lazy"
h={280}
fit="cover"
/>
</Box>
</Flex>
@ -432,17 +423,13 @@ export default function StartPage() {
</Text>
<SimpleGrid cols={{ base: 1, xs: 2, md: 3 }} spacing="lg">
{platformParts.map((p) => (
<Card key={p.title} withBorder padding="lg" radius="lg">
<ThemeIcon size={44} radius="md" variant="light" mb="sm">
{p.icon}
</ThemeIcon>
<Title order={4} mb="xs">
{p.title}
</Title>
<Text size="sm" c="dimmed">
{p.text}
</Text>
</Card>
<FeatureCard
key={p.title}
icon={p.icon}
title={p.title}
text={p.text}
iconSize={44}
/>
))}
</SimpleGrid>
</div>
@ -486,23 +473,18 @@ export default function StartPage() {
</Card>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="lg">
{otherBenefits.map((b) => (
<Card key={b.title} withBorder padding="lg" radius="lg">
<ThemeIcon size={40} radius="md" variant="light" mb="sm">
{b.icon}
</ThemeIcon>
<Title order={4} mb="xs">
{b.title}
</Title>
<Text size="sm" c="dimmed">
{b.text}
</Text>
</Card>
<FeatureCard
key={b.title}
icon={b.icon}
title={b.title}
text={b.text}
/>
))}
</SimpleGrid>
</div>
{/* Integrations */}
<Card withBorder padding="xl" radius="lg">
<SectionCard>
<Title order={2} mb="xs" ta="center">
Passt zu Ihrer bestehenden IT
</Title>
@ -516,7 +498,7 @@ export default function StartPage() {
</Badge>
))}
</Group>
</Card>
</SectionCard>
{/* Examples */}
<div>
@ -538,17 +520,12 @@ export default function StartPage() {
<SectionTitle>Darauf können Sie sich verlassen</SectionTitle>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="lg">
{trust.map((t) => (
<Card key={t.title} withBorder padding="lg" radius="lg">
<ThemeIcon size={40} radius="md" variant="light" mb="sm">
{t.icon}
</ThemeIcon>
<Title order={4} mb="xs">
{t.title}
</Title>
<Text size="sm" c="dimmed">
{t.text}
</Text>
</Card>
<FeatureCard
key={t.title}
icon={t.icon}
title={t.title}
text={t.text}
/>
))}
</SimpleGrid>
</div>
@ -586,14 +563,10 @@ export default function StartPage() {
</Button>
</Box>
<Box style={{ flex: 5 }} w={{ base: "100%", md: "auto" }}>
<Image
<PhotoImage
src="/photos/pilot-meeting.webp"
alt="Persönliches Beratungsgespräch mit Notizen am Tisch"
radius="lg"
className="photo"
loading="lazy"
h={300}
fit="cover"
/>
</Box>
</Flex>
@ -631,14 +604,10 @@ export default function StartPage() {
</Button>
</Box>
<Box style={{ flex: 5 }} w={{ base: "100%", md: "auto" }}>
<Image
<PhotoImage
src="/photos/church-pews.webp"
alt="Ruhiger Kirchenraum mit leeren Bänken"
radius="lg"
className="photo"
loading="lazy"
h={260}
fit="cover"
/>
</Box>
</Flex>

View file

@ -1,43 +1,31 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Code, Table, Text, Title } from "@mantine/core";
import { IconAlertCircle } from "@tabler/icons-react";
import { ApiError, api, type Run } from "../api";
import { Table, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import { useQuery } from "@tanstack/react-query";
import { api } from "../api";
import { formatApiError } from "../utils";
import ApprovalsTab from "../components/ApprovalsTab";
import { ErrorAlert } from "../components/ErrorAlert";
import { JsonView } from "../components/JsonView";
import StatusBadge from "../components/StatusBadge";
import { useEffect, useState } from "react";
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));
}
}, []);
const runsQuery = useQuery({
queryKey: ["all-runs"],
queryFn: api.listAllRuns,
});
const runs = runsQuery.data ?? [];
useEffect(() => {
void load();
}, [load]);
if (runsQuery.error) setError(formatApiError(runsQuery.error));
}, [runsQuery.error]);
return (
<div>
<Title order={2} mb="md">
Aufgaben
</Title>
{error && (
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
<ErrorAlert message={error} />
<Title order={3} mb="xs">
Eigene Läufe
</Title>
@ -52,17 +40,23 @@ export default function TasksPage() {
<Table.Th>Status</Table.Th>
<Table.Th>Flow</Table.Th>
<Table.Th>Ergebnis</Table.Th>
<Table.Th>Erstellt</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{runs.map(({ flow, run }) => (
{runs.map(({ flow_name, run }) => (
<Table.Tr key={run.id}>
<Table.Td>
<StatusBadge status={run.status} />
</Table.Td>
<Table.Td>{flow}</Table.Td>
<Table.Td>{flow_name}</Table.Td>
<Table.Td>
<Code>{run.output?.result ?? run.output?.error ?? "–"}</Code>
<JsonView
value={run.output?.result ?? run.output?.error ?? "–"}
/>
</Table.Td>
<Table.Td>
{dayjs(run.created_at).format("DD.MM.YYYY HH:mm")}
</Table.Td>
</Table.Tr>
))}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import Form from "@rjsf/mantine";
import validator from "@rjsf/validator-ajv8";
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
@ -11,57 +11,67 @@ import {
Textarea,
Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconPlayerPlay } from "@tabler/icons-react";
import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, type Run } from "../api";
import { RunsTable } from "../components/RunsTable";
import { ErrorAlert } from "../components/ErrorAlert";
import { formatApiError } from "../utils";
export default function WorkflowsPage() {
const [flows, setFlows] = useState<FlowDefinition[]>([]);
const queryClient = useQueryClient();
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(formatApiError(e));
}, []);
const flowsQuery = useQuery({
queryKey: ["flows"],
queryFn: api.listFlows,
});
const flows = flowsQuery.data ?? [];
useEffect(() => {
api
.listFlows()
.then((list) => {
setFlows(list);
const first = list[0];
if (first) setFlowId((prev) => prev || first.id);
})
.catch(fail);
}, [fail]);
const first = flows[0];
if (first) setFlowId((prev) => prev || first.id);
}, [flows]);
useEffect(() => {
if (!flowId) return;
setError("");
if (flowsQuery.error) setError(formatApiError(flowsQuery.error));
}, [flowsQuery.error]);
const schemaQuery = useQuery({
queryKey: ["input-schema", flowId],
queryFn: () => api.getInputSchema(flowId),
enabled: !!flowId,
retry: false,
});
const runsQuery = useQuery({
queryKey: ["runs", flowId],
queryFn: () => api.listRuns(flowId),
enabled: !!flowId,
});
const schema = schemaQuery.data ?? null;
const runs = runsQuery.data ?? [];
useEffect(() => {
setFormData({});
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]);
const e = schemaQuery.error;
if (!e) {
setError("");
return;
}
// Flows without a deployed version have no schema yet.
if (e instanceof ApiError && e.status === 404) {
setError("Für diesen Flow ist noch keine Version deployed.");
} else {
setError(formatApiError(e));
}
}, [flowId, schemaQuery.error]);
useEffect(() => {
if (runsQuery.error) setError(formatApiError(runsQuery.error));
}, [runsQuery.error]);
async function onRun(input: unknown) {
if (!flowId) return;
@ -69,11 +79,21 @@ export default function WorkflowsPage() {
setError("");
setResult("");
try {
const run = await api.runFlow(flowId, input);
setRuns((prev) => [run, ...prev]);
setResult(run.output?.result ?? run.output?.error ?? run.status);
const run: Run = await api.runFlow(flowId, input);
setResult(
typeof run.output?.result === "string"
? run.output.result
: (run.output?.error ?? run.status),
);
notifications.show({
title: "Lauf gestartet",
message: `Status: ${run.status}`,
color: "green",
});
await queryClient.invalidateQueries({ queryKey: ["runs", flowId] });
await queryClient.invalidateQueries({ queryKey: ["all-runs"] });
} catch (e) {
fail(e);
setError(formatApiError(e));
} finally {
setBusy(false);
}