Compare commits
28 commits
af993bd43f
...
b1c8d6c66e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1c8d6c66e | ||
|
|
25cf645295 | ||
|
|
c3c4986979 | ||
|
|
e82ce391f6 | ||
|
|
d903d0951f | ||
|
|
94e5e53092 | ||
|
|
cfcf0e8bd6 | ||
|
|
a6cc585dd0 | ||
|
|
a8e312a4f8 | ||
|
|
2d7773dbca | ||
|
|
fa9d99d5c0 | ||
|
|
c1d505238f | ||
|
|
9420ce10cf | ||
|
|
c948657b25 | ||
|
|
df5c1e296c | ||
|
|
672faf3af8 | ||
|
|
b533549e45 | ||
|
|
82f7ff8b02 | ||
|
|
85c5a8381c | ||
|
|
38d4899b07 | ||
|
|
6e12720ad6 | ||
|
|
62d01b5097 | ||
|
|
46ecab00db | ||
|
|
80d81b8022 | ||
|
|
d4aeab8dbf | ||
|
|
c8280f7078 | ||
|
|
c2125f063b | ||
|
|
1e8050611c |
|
|
@ -11,14 +11,23 @@
|
|||
},
|
||||
"mounts": [
|
||||
"source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly",
|
||||
"source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind"
|
||||
"source=kirche-opencode-share,target=/home/vscode/.local/share/opencode,type=volume",
|
||||
"source=kirche-opencode-config,target=/home/vscode/.config/opencode,type=volume"
|
||||
],
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}
|
||||
// moby:false — python:3.12-slim is Debian trixie now, whose repos have
|
||||
// no moby-cli (feature default). false installs the upstream Docker CE
|
||||
// CLI instead, which supports trixie.
|
||||
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
|
||||
"moby": false
|
||||
}
|
||||
},
|
||||
"overrideCommand": false,
|
||||
"postCreateCommand": "sudo -E bash .devcontainer/setup.sh",
|
||||
"postStartCommand": "bash .devcontainer/postStart.sh",
|
||||
// Absolute paths: lifecycle commands run in the image WORKDIR
|
||||
// (/srv/kirche-einfach/backend), not the workspace folder — relative
|
||||
// .devcontainer/... paths fail with "No such file or directory" (exit 127).
|
||||
"postCreateCommand": "sudo -E bash /srv/kirche-einfach/.devcontainer/setup.sh",
|
||||
"postStartCommand": "bash /srv/kirche-einfach/.devcontainer/postStart.sh",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
|
@ -29,7 +38,7 @@
|
|||
"esbenp.prettier-vscode"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/srv/kirche-einfach/backend/.venv/bin/python"
|
||||
"python.defaultInterpreterPath": "/opt/venv/bin/python"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,31 +2,73 @@
|
|||
# Dev-only setup (runs via postCreateCommand in the attached `api` container).
|
||||
# Production needs none of this: images carry their dependencies and the
|
||||
# api entrypoint migrates on every start.
|
||||
set -e
|
||||
set -eo pipefail
|
||||
|
||||
REPO=/srv/kirche-einfach
|
||||
|
||||
# Run a command as vscode with the current environment intact.
|
||||
# (`su vscode -c` resets the environment, which used to drop
|
||||
# UV_PROJECT_ENVIRONMENT/UV_PYTHON_INSTALL_DIR plus every compose-provided
|
||||
# variable — uv then created a project-local backend/.venv in the bind-mount
|
||||
# instead of using /opt/venv, and sync_flows.py ran without its settings.)
|
||||
# -H repoints HOME to /home/vscode: without it HOME stays /root (from the
|
||||
# outer sudo) and uv/pip caches fail with Permission denied.
|
||||
as_vscode() {
|
||||
sudo -H -u vscode -E bash -c "$*"
|
||||
}
|
||||
|
||||
# Venv lives outside the bind-mount (see backend/Dockerfile).
|
||||
export UV_PYTHON_INSTALL_DIR=/opt/python UV_PROJECT_ENVIRONMENT=/opt/venv
|
||||
|
||||
echo "=== Setting up kirche-einfach ==="
|
||||
|
||||
# Backend deps into the venv volume (setup runs via sudo; handed to vscode below).
|
||||
# Writable dirs first: this script runs via sudo as root, but the editor and
|
||||
# most commands run as vscode. (A root-created backend/data/ or venv used to
|
||||
# break the DB and the interpreter for vscode.)
|
||||
mkdir -p "$REPO/backend/data" \
|
||||
"$REPO/frontend/node_modules" \
|
||||
/opt/python /opt/venv \
|
||||
/home/vscode/.cache \
|
||||
/home/vscode/.local/share/uv \
|
||||
/home/vscode/.local/share/opencode \
|
||||
/home/vscode/.config/opencode
|
||||
chown -R vscode:vscode "$REPO/backend/data" \
|
||||
"$REPO/frontend/node_modules" \
|
||||
/opt/python /opt/venv \
|
||||
/home/vscode/.cache \
|
||||
/home/vscode/.local/share/uv \
|
||||
/home/vscode/.local/share/opencode \
|
||||
/home/vscode/.config/opencode 2>/dev/null || true
|
||||
|
||||
# Backend deps into /opt/venv, as vscode (a root-owned venv is unusable for
|
||||
# the editor; uv resolves at setup time, no lockfile is committed).
|
||||
echo "--- Installing Python dependencies ---"
|
||||
cd "$REPO/backend"
|
||||
uv sync
|
||||
as_vscode "uv sync"
|
||||
|
||||
# opencode CLI (dev tooling; intentionally not baked into the image).
|
||||
if [ ! -x /home/vscode/.opencode/bin/opencode ]; then
|
||||
echo "--- Installing opencode ---"
|
||||
su vscode -c "curl -fsSL https://opencode.ai/install | bash"
|
||||
as_vscode "curl -fsSL https://opencode.ai/install | bash"
|
||||
ln -sf /home/vscode/.opencode/bin/opencode /usr/local/bin/opencode
|
||||
fi
|
||||
|
||||
# Compose services (langflow, lfx) must be up; fail loudly otherwise.
|
||||
# (api/frontend are starting alongside this script.)
|
||||
echo "--- Waiting for compose services ---"
|
||||
# Fresh clones have no `.env` (gitignored) and compose `env_file` is
|
||||
# optional, so provision one from the example instead of failing setup.
|
||||
# Fill in real secrets afterwards; empty SMTP_* means the API logs mail.
|
||||
if [ ! -f "$REPO/.env" ]; then
|
||||
echo "ERROR: $REPO/.env is missing. Copy .env.example to .env and fill in secrets." >&2
|
||||
exit 1
|
||||
echo "WARN: $REPO/.env missing — copying .env.example (fill in secrets)."
|
||||
cp "$REPO/.env.example" "$REPO/.env"
|
||||
chown vscode:vscode "$REPO/.env" 2>/dev/null || true
|
||||
fi
|
||||
# Dev needs the Vite HMR server; the compose default is fail-closed preview.
|
||||
if ! grep -q "^FRONTEND_MODE=" "$REPO/.env" 2>/dev/null; then
|
||||
echo "FRONTEND_MODE=dev" >> "$REPO/.env"
|
||||
chown vscode:vscode "$REPO/.env" 2>/dev/null || true
|
||||
fi
|
||||
echo "--- Waiting for compose services ---"
|
||||
python3 - <<'EOF'
|
||||
import socket
|
||||
import sys
|
||||
|
|
@ -36,13 +78,13 @@ targets = [("langflow", 7860), ("lfx", 6004)]
|
|||
deadline = time.time() + 180
|
||||
pending = dict(targets)
|
||||
while pending and time.time() < deadline:
|
||||
for host, port in list(pending):
|
||||
for host, port in list(pending.items()):
|
||||
s = socket.socket()
|
||||
s.settimeout(2)
|
||||
try:
|
||||
s.connect((host, port))
|
||||
print(f"OK {host}:{port}", flush=True)
|
||||
del pending[(host, port)]
|
||||
del pending[host]
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
|
|
@ -57,13 +99,18 @@ EOF
|
|||
# entrypoint on every start).
|
||||
echo "--- Syncing canonical flows ---"
|
||||
cd "$REPO/backend"
|
||||
uv run python scripts/sync_flows.py --sync || echo "WARN: flow sync failed (check backend/flows and LFX)"
|
||||
as_vscode "uv run python scripts/sync_flows.py --sync" || echo "WARN: flow sync failed (check backend/flows and LFX)"
|
||||
|
||||
echo "--- Installing Git Credential Manager ---"
|
||||
# GCM (Linux) depends on libicu (.NET runtime) — runtime libs only.
|
||||
apt-get install -y -qq libicu76 2>/dev/null || true
|
||||
GCM_DEB_URL="https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.9.1/gcm-linux-x64-2.9.1.deb"
|
||||
GCM_DEB="/tmp/gcm-linux-x64-2.9.1.deb"
|
||||
curl -fsSL "$GCM_DEB_URL" -o "$GCM_DEB"
|
||||
dpkg -i "$GCM_DEB" 2>/dev/null || apt-get install -f -y
|
||||
as_vscode "git config --global credential.helper manager"
|
||||
rm -f "$GCM_DEB"
|
||||
|
||||
echo ""
|
||||
echo "=== Setup complete ==="
|
||||
echo "Forgejo username: $FORGEJO_USERNAME"
|
||||
|
||||
# Hand dirs back to vscode (this script runs via sudo; uv creates caches as root)
|
||||
chown -R vscode:vscode "$REPO/backend/.venv" \
|
||||
"$REPO/frontend/node_modules" \
|
||||
/home/vscode/.cache 2>/dev/null || true
|
||||
|
|
|
|||
39
.env.example
|
|
@ -1,12 +1,21 @@
|
|||
# Copy to `.env` and fill in values. `.env` is gitignored.
|
||||
# It holds only ubiquitous variables (identical value in dev and prod).
|
||||
# Dev-only variables travel via devcontainer.json `containerEnv`
|
||||
# (e.g. APP_ENV=dev); paths resolve from the repo root in code.
|
||||
# Generate secrets with: openssl rand -hex 32
|
||||
# Validate before deploying: uv run python scripts/check_prod.py --env ../.env
|
||||
# Dev defaults live in docker-compose.yml (APP_ENV=dev, FORGEJO_USERNAME=jonas).
|
||||
# Prod overrides via this .env file (e.g. APP_ENV=prod).
|
||||
# Paths resolve from the repo root in code.
|
||||
FORGEJO_TOKEN=
|
||||
|
||||
# --- langflow (authoring UI) ---
|
||||
# --- langflow (authoring UI) + lfx (flow runtime): pinned in lockstep ---
|
||||
# LFX contract: major.minor of Langflow and LFX must match (patches are
|
||||
# independent). Bump deliberately on both sides together (compose image pin
|
||||
# below + Dockerfile.lfx ARG + backend dev dep `lfx~=`), then re-check
|
||||
# backend/flows/*.json with `lfx upgrade --upgrade-flow=safe`.
|
||||
LANGFLOW_VERSION=1.12.0
|
||||
LANGFLOW_URL=http://langflow:7860
|
||||
LANGFLOW_SUPERUSER=langflow
|
||||
# Long random values (check_prod.py enforces length). Never leave the
|
||||
# placeholders: the authoring UI is internet-facing.
|
||||
LANGFLOW_SUPERUSER_PASSWORD=
|
||||
LANGFLOW_SECRET_KEY=
|
||||
# Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope.
|
||||
|
|
@ -19,9 +28,27 @@ LFX_API_KEY=lfx-
|
|||
|
||||
# --- API self-reference for flow callbacks (must be reachable FROM lfx) ---
|
||||
CALLBACK_BASE_URL=http://api:8000
|
||||
# Shared secret for flow callbacks (POST /api/approvals carries it as
|
||||
# X-Callback-Token). Long random value; empty disables callbacks.
|
||||
CALLBACK_TOKEN=
|
||||
|
||||
# --- frontend mode: dev server with HMR (prod .env must set `preview`) ---
|
||||
FRONTEND_MODE=dev
|
||||
# --- frontend mode: unset/preview serves the production bundle ---
|
||||
# Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it.
|
||||
# Never set `dev` in prod: the Vite dev server must not face the internet.
|
||||
# FRONTEND_MODE=dev
|
||||
|
||||
# --- access management (request + approval, invite via email) ---
|
||||
# Public frontend URL (magic links point here).
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
# Comma-separated emails that become admins (role assignment + bootstrap).
|
||||
ADMIN_EMAILS=
|
||||
# Reply-To on outgoing mail; also receives new access-request notifications.
|
||||
CONTACT_TO=
|
||||
MAIL_FROM=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# --- LLM (optional; tests mock the provider without it) ---
|
||||
# LLM_API_KEY=
|
||||
|
|
@ -19,4 +19,6 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cd backend && uv sync
|
||||
- run: cd backend && uv run ruff check .
|
||||
- run: cd backend && uv run ruff format . --check --diff
|
||||
- run: cd backend && uv run pytest
|
||||
|
|
|
|||
4
.gitignore
vendored
|
|
@ -26,6 +26,10 @@ backend/data/
|
|||
*.swp
|
||||
*.swo
|
||||
|
||||
# Devcontainer
|
||||
.devcontainer/devcontainer-lock.json
|
||||
.devcontainer/opencode-data/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
29
AGENTS.md
|
|
@ -7,6 +7,8 @@ Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/
|
|||
Backend (run from `backend/`):
|
||||
- `uv sync` — install dependencies
|
||||
- `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed)
|
||||
- `uv run ruff check .` — lint (no findings allowed; only pre-exempt `EXE002`, see `[tool.ruff.lint.ignore]`)
|
||||
- `uv run ruff format .` — code formatting (run after edits; CI checks with `--check --diff`)
|
||||
- `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored)
|
||||
- `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX
|
||||
- `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent)
|
||||
|
|
@ -32,6 +34,11 @@ profiles, or override files. Containers: `api` (FastAPI :8000, attached),
|
|||
- Prod: own `.env` with secrets + `FRONTEND_MODE=preview`; code updates via
|
||||
`git pull` + restart, image rebuilds only for dependency changes
|
||||
(`docker compose up -d --build`).
|
||||
- Git in the container uses HTTPS with GCM (`credential.helper=manager`,
|
||||
installed by `.devcontainer/setup.sh` together with `libicu76` runtime).
|
||||
Remote URL has no embedded token. First push/pull triggers a device-code
|
||||
prompt (open URL on host). Rotate GitLab tokens in GitLab UI before
|
||||
switching credentials.
|
||||
|
||||
## Definition of done
|
||||
|
||||
|
|
@ -62,3 +69,25 @@ report = await sync_from_directory(session, lfx, settings.flows_dir)
|
|||
## Git
|
||||
|
||||
When committing: `git add -A && git commit -m "<message>"` (conventional commits: `feat:`, `fix:`, `chore:`, `docs:`). When pushing: `git push` (branch is tracking `origin/main`).
|
||||
|
||||
## Versioning
|
||||
|
||||
- `VERSION` (repo root) is canonical — currently `0.1.0`.
|
||||
- Backend: `backend/pyproject.toml` version; Frontend: `frontend/package.json` version — bump both together with VERSION on every release.
|
||||
- SemVer: feat → minor (X.Y+1.0), fix → patch (X.Y.Z+1), breaking → major (X+1.0.0). Tag `vX.Y.Z` at release merge.
|
||||
- Changelog: every user-visible change gets an `[Unreleased]` entry (Keep a Changelog categories) before commit; at release move it into `[X.Y.Z] - YYYY-MM-DD`.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- New work branches from `main`: `feat/<short>` for features, `fix/<short>` for fixes.
|
||||
- Push branch, open MR, merge `--no-ff` on green CI, delete branch.
|
||||
- Never push directly to `main` for features or fixes.
|
||||
|
||||
## Web-dev digest
|
||||
|
||||
- Small batches: PR diff ≤ ~200 lines ideal; trunk-based with short-lived branches (above).
|
||||
- Tests green before merge (definition of done); `npm run build` green for frontend.
|
||||
- Docs in same PR as feature (README, AGENTS, CHANGELOG).
|
||||
- Deprecate in minor, remove in next major; document Removed/Deprecated in CHANGELOG.
|
||||
- Lockfiles (uv.lock, package-lock.json, compose pins) — don't drift without reason.
|
||||
- Future implementation ideas → `ROADMAP.md` (authoritative); `CHANGELOG.md` is historical only.
|
||||
|
|
|
|||
49
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
### Security
|
||||
- Production ops: `scripts/check_prod.py` validates `.env` secrets and
|
||||
modes before deploy (with tests); README production checklist
|
||||
(proxy gates for Langflow, secret rotation, upgrade runbook).
|
||||
### Security
|
||||
- API authorization: flow management is admin-only, runs and run/approval
|
||||
reads require login with strict session-tenant scoping, approval
|
||||
decisions record the session user, and flow callbacks authenticate with
|
||||
a shared `X-Callback-Token` (`CALLBACK_TOKEN`). Request-controlled
|
||||
`tenant_id`, `callback_base_url` and `continuation_flow_id` were
|
||||
removed (SSRF / cross-tenant / flow-chaining hardening);
|
||||
continuations now resolve from the admin-authored definition.
|
||||
- Frontend: `/app/*` routes require login; approver is the session user.
|
||||
- Edge hardening: interactive API docs only with debug, security headers
|
||||
(nosniff/frame/Referrer-Policy), 2 MiB body cap, per-IP run throttle,
|
||||
generic upstream-error responses, magic links never logged.
|
||||
- Containers run rootless (api/vite as uid 1000, lfx as `lfx` user);
|
||||
frontend fails closed to preview mode unless `FRONTEND_MODE=dev`.
|
||||
### Added
|
||||
- Landing page: status-quo problem block, 5-step process with quote,
|
||||
portal overview with admin areas, AI platform parts, exception tiers,
|
||||
quantified benefits, integrations, use-case examples, pilot-program
|
||||
section, expanded FAQ (8 items incl. KI data usage, price model,
|
||||
responsibility).
|
||||
### Changed
|
||||
- Landing page copy overhauled after 3 reviewer rounds (church-admin
|
||||
compliance wording: "entwickelt nach DSG-EKD/DSGVO" + AVV/TOMs,
|
||||
human release responsibility, honest pilot framing instead of
|
||||
invented testimonials; CTA now "Pilotgespräch vereinbaren").
|
||||
- New photo `pilot-meeting.webp` (consultation scene, self-hosted).
|
||||
- Backend lint + format standards via ruff (pinned dev dependency,
|
||||
enforced in CI): `uv run ruff check .`, `uv run ruff format`.
|
||||
|
||||
## [0.1.0] - 2026-09-13
|
||||
### Added
|
||||
- Repo scaffold: docker-compose dev/prod, version-pinned images, devcontainer.
|
||||
- Magic-link auth, access requests, RBAC, frontend access-management UI.
|
||||
- File-based flow source of truth + sync engine + LFX runtime.
|
||||
- RJSF dynamic forms, router, Mantine dark-mode design system.
|
||||
- Conversion landing page, automated screenshots, bell-tower favicon.
|
||||
- Email-first login, branded multipart HTML magic-link mails.
|
||||
- lfx level-4 validation on all canonical flows + regression test.
|
||||
|
|
@ -8,12 +8,21 @@ ENV PYTHONUNBUFFERED=1 \
|
|||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
# lfx = flow runtime; requests = convenience for custom-component code.
|
||||
RUN pip install lfx requests
|
||||
# Pinned to the Langflow minor (contract: major.minor must match Langflow;
|
||||
# patches independent). Bump together with LANGFLOW_VERSION (.env.example).
|
||||
ARG LFX_VERSION=1.12.0
|
||||
RUN pip install "lfx~=${LFX_VERSION}" requests
|
||||
|
||||
RUN mkdir -p /flows
|
||||
# Non-root runtime: uploads persist into the mounted volume.
|
||||
RUN useradd -r -s /bin/false lfx && mkdir -p /flows && chown lfx /flows
|
||||
VOLUME /flows
|
||||
EXPOSE 6004
|
||||
USER lfx
|
||||
|
||||
# Serve the /flows dir (uploaded flows persist there via --flow-dir).
|
||||
# Flags verified against `lfx serve --help` (lfx 1.12.0).
|
||||
CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows", "/flows"]
|
||||
# No positional path: with one, lfx>=1.12.1 exits when the dir holds no
|
||||
# .json yet ("No .json files found"), restart-looping the container on
|
||||
# first boot. --flow-dir alone starts clean (health 200); the API sync
|
||||
# deploys backend/flows/*.json right after boot.
|
||||
# Flags verified against `lfx serve --help` (lfx 1.12.x).
|
||||
CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows"]
|
||||
|
|
|
|||
49
README.md
|
|
@ -22,8 +22,28 @@ needs judgment. Every step is documented and traceable.
|
|||
deploy to the lightweight LFX runtime and can run synchronously or streamed.
|
||||
- **Approvals** — a flow can pause and ask a human for a decision. Approving
|
||||
can automatically start a follow-up ("continuation") flow.
|
||||
- **Web UI** — three tabs: *Vorgänge* (flows and runs), *Freigaben*
|
||||
(approvals), *Administration* (import, sync, deploy).
|
||||
- **Access management** — public landing page (German) with an access-request
|
||||
form (request + approval). Approved users get a passwordless magic-link
|
||||
invite by email (SMTP via `.env`); sessions are HttpOnly cookies, tenants
|
||||
come from the session. First admin via `POST /api/auth/admin-bootstrap`
|
||||
(only on an empty user table, email must be in `ADMIN_EMAILS`).
|
||||
- **Dynamic forms** — flows declare form fields as typed inputs on a
|
||||
`CustomInput*` custom component (reusable library in
|
||||
`backend/components/`, mounted into Langflow via
|
||||
`LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a
|
||||
JSON Schema + RJSF uiSchema from the deployed version; the webapp renders
|
||||
the matching form automatically. Flows without such a node fall back to a
|
||||
single textarea. See
|
||||
`backend/components/kirche/README.md` for authoring new form fields.
|
||||
- **Web UI** — landing page (*Start*), access request (*Zugang*), and the
|
||||
logged-in webapp: *Workflows* (choose + run with dynamic form),
|
||||
*Aufgaben* (approvals), *Verwaltung* (admin: access requests, import,
|
||||
sync, deploy).
|
||||
|
||||
## Future
|
||||
|
||||
Ideas and planned features live in [ROADMAP.md](ROADMAP.md)
|
||||
(the single authoritative place for future implementation ideas).
|
||||
|
||||
## Infrastructure
|
||||
|
||||
|
|
@ -106,15 +126,32 @@ Flow workflow: design in Langflow → export into `backend/flows/<name>.json`
|
|||
(`--pull`, or UI download) → commit → the watcher deploys it automatically.
|
||||
Deleting a file removes the flow including its runs (git keeps the JSON).
|
||||
|
||||
## Production (self-hosted, internal use — no TLS/reverse proxy included)
|
||||
## Production (self-hosted — TLS/reverse proxy required, not included)
|
||||
|
||||
1. Check out the repo on the host, create `.env` from `.env.example` with
|
||||
real secrets and `FRONTEND_MODE=preview`.
|
||||
2. `docker compose up -d --build`.
|
||||
3. Code updates via `git pull` + `docker compose restart api frontend`
|
||||
real secrets (`openssl rand -hex 32`) and `FRONTEND_MODE=preview`.
|
||||
2. Validate it: `uv run python scripts/check_prod.py --env ../.env`
|
||||
(run from `backend/`; blocks on weak secrets or dev modes).
|
||||
3. `docker compose up -d --build`.
|
||||
4. Code updates via `git pull` + `docker compose restart api frontend`
|
||||
(the checkout is bind-mounted); image rebuilds only for dependency
|
||||
changes (`docker compose up -d --build`).
|
||||
|
||||
Internet exposure checklist (both forwarded ports sit behind your HTTPS
|
||||
proxy — nothing here substitutes for it):
|
||||
|
||||
- Proxy: force HTTPS (+ HSTS), and put an extra login (basic-auth or
|
||||
OIDC) in front of Langflow `:7860`. Langflow has a history of
|
||||
critical unauthenticated RCEs — treat its UI as privileged tooling,
|
||||
never share flows publicly ("Shareable Playground" = RCE risk).
|
||||
- Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate
|
||||
after any incident or staff change; re-run `check_prod.py`.
|
||||
- Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION`
|
||||
(`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then
|
||||
`lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on
|
||||
`backend/flows/*.json`. Check Langflow security advisories monthly.
|
||||
- Backups: `backend/data/` (SQLite) and the `langflow-data` volume.
|
||||
|
||||
Container updates: registry images (`langflowai/langflow:latest`)
|
||||
auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built
|
||||
images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`.
|
||||
|
|
|
|||
20
ROADMAP.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Roadmap
|
||||
|
||||
Future implementation ideas for kirche-einfach.
|
||||
Ideas live here until they ship (then a `feat:` commit + `[Unreleased]`
|
||||
entry in CHANGELOG.md). Keep entries short, ordered by priority.
|
||||
|
||||
This file is the **authoritative place** for future ideas.
|
||||
`CHANGELOG.md` is historical (what shipped); `AGENTS.md` is rules/architecture.
|
||||
|
||||
## Planned
|
||||
|
||||
(none yet — add ideas here as they emerge)
|
||||
|
||||
## Considered / not started
|
||||
|
||||
- **Member form-filling page** — members fill out forms (attendance, sign-up) via the same input schema used by admins to *run* flows.
|
||||
- **Approval decision flow** — complete approve/reject-with-reason + member notification for access requests.
|
||||
- **E2E tests** — Playwright/Cypress smoke tests across key user journeys.
|
||||
- **CI pipeline** — complete Forgejo workflows (backend pytest, frontend build, compose validation, lint).
|
||||
- **Audit log** — track who submitted/approved what (church compliance).
|
||||
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.0
|
||||
9
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Keep the build context lean: only pyproject/alembic/entrypoint plus
|
||||
# app/migrations/scripts are COPYed. Local venvs, databases and caches must
|
||||
# never enter the context (slow builds, root-owned file leaks).
|
||||
.venv/
|
||||
data/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
uv.lock
|
||||
|
|
@ -4,17 +4,28 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
# Venv + managed Python live outside the repo bind-mount: a venv under
|
||||
# backend/ used to be shadowed by a named volume (empty on first start)
|
||||
# and backed by a root-owned interpreter vscode could not execute.
|
||||
# /opt is image-local, world-readable, identical in dev and prod.
|
||||
UV_PYTHON_INSTALL_DIR=/opt/python \
|
||||
UV_PROJECT_ENVIRONMENT=/opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
# Foundational, inert in prod: non-root user for the editor, sudo for setup,
|
||||
# openssh-client for the devcontainer post-start hook.
|
||||
# openssh-client for the devcontainer post-start hook, curl for the opencode
|
||||
# installer in setup.sh, git for the VS Code source-control view, gosu to
|
||||
# drop privileges before serving (the server never runs as root).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends sudo openssh-client \
|
||||
&& apt-get install -y --no-install-recommends sudo openssh-client curl git ca-certificates gosu \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -m -s /bin/bash vscode \
|
||||
&& echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode
|
||||
&& echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode \
|
||||
&& mkdir -p /opt/python /opt/venv \
|
||||
&& chmod 755 /opt/python /opt/venv
|
||||
|
||||
WORKDIR /srv/kirche-einfach/backend
|
||||
COPY pyproject.toml alembic.ini entrypoint.sh ./
|
||||
|
|
@ -23,9 +34,12 @@ COPY migrations ./migrations
|
|||
COPY scripts ./scripts
|
||||
# No lockfile is committed; dependencies resolve at build time.
|
||||
# Full groups (incl. dev): pytest and friends must work in dev with zero steps.
|
||||
RUN uv sync && chmod +x entrypoint.sh
|
||||
# a+rX: the image builds as root, but the attached devcontainer runs as vscode.
|
||||
RUN uv sync && chmod +x entrypoint.sh && chmod -R a+rX /opt/python /opt/venv
|
||||
|
||||
ENV FLOWS_WATCH_ENABLED=true
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
# Explicit interpreter: the repo bind-mount shadows the image at runtime and
|
||||
# git tracks entrypoint.sh as 100644, so the exec bit cannot be relied on.
|
||||
ENTRYPOINT ["/bin/sh", "./entrypoint.sh"]
|
||||
|
|
|
|||
1
backend/app/auth/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Magic-link authentication + access-request approval (SMTP invite)."""
|
||||
192
backend/app/auth/api.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"""Access + auth HTTP API (magic-link login, session cookie)."""
|
||||
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models import User
|
||||
|
||||
from . import mail, service
|
||||
from .deps import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_MAX_AGE,
|
||||
get_current_user,
|
||||
get_db,
|
||||
require_admin,
|
||||
)
|
||||
from .schemas import (
|
||||
AccessDecisionRead,
|
||||
AccessRequestCreate,
|
||||
AccessRequestRead,
|
||||
AdminBootstrapRequest,
|
||||
MagicConsumeRequest,
|
||||
MagicRequestRead,
|
||||
MagicRequestRequest,
|
||||
MeRead,
|
||||
MessageRead,
|
||||
)
|
||||
|
||||
access_router = APIRouter(prefix="/api/access", tags=["access"])
|
||||
auth_router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _read_request(r) -> AccessRequestRead:
|
||||
return AccessRequestRead(
|
||||
id=r.id,
|
||||
name=r.name,
|
||||
email=r.email,
|
||||
reason=r.reason,
|
||||
status=r.status,
|
||||
created_at=r.created_at,
|
||||
decided_at=r.decided_at,
|
||||
decided_by=r.decided_by,
|
||||
)
|
||||
|
||||
|
||||
def _read_me(user: User) -> MeRead:
|
||||
return MeRead(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
role=user.role,
|
||||
tenant_id=user.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, session_id: str) -> None:
|
||||
# localhost is a secure context, so Secure is safe for local dev too.
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
session_id,
|
||||
max_age=SESSION_MAX_AGE,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
@access_router.post("/requests", response_model=AccessRequestRead, status_code=201)
|
||||
def create_request(data: AccessRequestCreate, session: Annotated[Session, Depends(get_db)]):
|
||||
try:
|
||||
request = service.create_access_request(session, data.name, data.email, data.reason)
|
||||
except service.RateLimitedError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
mail.send_access_notification(str(request.id), request.name, request.email)
|
||||
return _read_request(request)
|
||||
|
||||
|
||||
@access_router.get("/requests", response_model=list[AccessRequestRead])
|
||||
def list_requests(
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
admin: Annotated[User, Depends(require_admin)],
|
||||
status: str | None = Query(default=None),
|
||||
):
|
||||
return [_read_request(r) for r in service.list_access_requests(session, status)]
|
||||
|
||||
|
||||
@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead)
|
||||
def approve_request(
|
||||
request_id: UUID,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
try:
|
||||
request, _user, token = service.approve_access_request(session, request_id, admin.email)
|
||||
except service.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except service.ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
# The token travels by mail only — never in an API response.
|
||||
email_sent = mail.send_magic_link(request.email, mail.magic_link(token))
|
||||
return AccessDecisionRead(request=_read_request(request), email_sent=email_sent)
|
||||
|
||||
|
||||
@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead)
|
||||
def reject_request(
|
||||
request_id: UUID,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
try:
|
||||
request = service.reject_access_request(session, request_id, admin.email)
|
||||
except service.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except service.ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return _read_request(request)
|
||||
|
||||
|
||||
@auth_router.post("/magic/request", response_model=MagicRequestRead)
|
||||
def request_magic(
|
||||
data: MagicRequestRequest,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
"""Email-first login: allowed emails get a magic link mailed.
|
||||
|
||||
403 means "not allowed" — the frontend forwards those to /access.
|
||||
The token travels by mail only, never in the response.
|
||||
"""
|
||||
try:
|
||||
user, token = service.request_magic_link(session, data.email)
|
||||
except service.ForbiddenError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except service.RateLimitedError as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
email_sent = mail.send_magic_link(user.email, mail.magic_link(token))
|
||||
return MagicRequestRead(message="login link sent", email_sent=email_sent)
|
||||
|
||||
|
||||
@auth_router.post("/magic/consume", response_model=MeRead)
|
||||
def consume_magic(
|
||||
data: MagicConsumeRequest,
|
||||
response: Response,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
try:
|
||||
user_session = service.consume_magic_token(session, data.token)
|
||||
except (
|
||||
service.InvalidTokenError,
|
||||
service.ExpiredTokenError,
|
||||
service.UsedTokenError,
|
||||
) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
user = service.get_user(session, user_session.user_id)
|
||||
if user is None: # pragma: no cover - consume already validated the user
|
||||
raise HTTPException(status_code=400, detail="unknown token")
|
||||
_set_session_cookie(response, user_session.id)
|
||||
return _read_me(user)
|
||||
|
||||
|
||||
@auth_router.get("/me", response_model=MeRead)
|
||||
def read_me(user: Annotated[User, Depends(get_current_user)]):
|
||||
return _read_me(user)
|
||||
|
||||
|
||||
@auth_router.post("/logout", response_model=MessageRead)
|
||||
def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
# Revoke the server-side row when the cookie maps to a live session;
|
||||
# always clear the client cookie.
|
||||
session_id = request.cookies.get(SESSION_COOKIE)
|
||||
if session_id:
|
||||
service.revoke_session(session, session_id)
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return MessageRead(message="logged out")
|
||||
|
||||
|
||||
@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201)
|
||||
def admin_bootstrap(data: AdminBootstrapRequest, session: Annotated[Session, Depends(get_db)]):
|
||||
try:
|
||||
_user, token = service.bootstrap_admin(session, data.email)
|
||||
except service.ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except service.ForbiddenError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
mail.send_magic_link(service.normalize_email(data.email), mail.magic_link(token))
|
||||
return MessageRead(message="admin created, magic link sent")
|
||||
54
backend/app/auth/deps.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Session-cookie dependencies. Handlers stay thin; DB via get_session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.db import get_session
|
||||
from app.models import User
|
||||
|
||||
from . import service
|
||||
|
||||
SESSION_COOKIE = "kirche_session"
|
||||
SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
yield from get_session()
|
||||
|
||||
|
||||
def get_optional_user(
|
||||
request: Request, session: Annotated[Session, Depends(get_db)]
|
||||
) -> User | None:
|
||||
session_id = request.cookies.get(SESSION_COOKIE)
|
||||
if not session_id:
|
||||
return None
|
||||
record = service.get_valid_session(session, session_id)
|
||||
if record is None:
|
||||
return None
|
||||
return service.get_user(session, record.user_id)
|
||||
|
||||
|
||||
def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User:
|
||||
user = get_optional_user(request, session)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="admin only")
|
||||
return user
|
||||
|
||||
|
||||
def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str:
|
||||
"""Tenant comes from the session; the body/query param is a deprecated
|
||||
fallback for unauthenticated callers."""
|
||||
if user is not None:
|
||||
return user.tenant_id
|
||||
return explicit or default
|
||||
178
backend/app/auth/mail.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"""Magic-link delivery. SMTP when configured, dev log fallback otherwise.
|
||||
|
||||
Mail design follows the web branding (see frontend/src/theme.ts):
|
||||
kirchenblau header, serif headline, white card on paper background.
|
||||
Email-safe by construction: table layout, inline styles only, system
|
||||
fonts, no external images (many clients block them / track opens).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Brand palette (mirrors frontend/src/theme.ts kirchenblau).
|
||||
BLUE = "#1d4a79"
|
||||
BLUE_LIGHT = "#eef4fb"
|
||||
INK = "#162f4c"
|
||||
MUTED = "#5b6b7c"
|
||||
|
||||
BLUE = "#1d4a79"
|
||||
BLUE_LIGHT = "#eef4fb"
|
||||
INK = "#162f4c"
|
||||
MUTED = "#5b6b7c"
|
||||
|
||||
|
||||
def magic_link(token: str) -> str:
|
||||
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
|
||||
|
||||
|
||||
def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str:
|
||||
"""Branded HTML mail body (table layout, inline styles, no images)."""
|
||||
button = ""
|
||||
if button_url:
|
||||
button = (
|
||||
'<table role="presentation" cellpadding="0" cellspacing="0" '
|
||||
'style="margin:24px auto 8px auto;"><tr>'
|
||||
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
|
||||
f'<a href="{button_url}" '
|
||||
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
|
||||
"Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;"
|
||||
"font-weight:600;color:#ffffff;text-decoration:none;"
|
||||
f'border-radius:8px;">{button_label}</a></td></tr></table>'
|
||||
)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
||||
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
|
||||
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
|
||||
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
|
||||
<tr>
|
||||
<td bgcolor="{BLUE}" style="padding:20px 28px;">
|
||||
<span style="color:#ffffff;font-size:18px;font-weight:700;
|
||||
letter-spacing:0.02em;">Kirche Einfach</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
|
||||
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
|
||||
color:{INK};">{title}</h1>
|
||||
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
|
||||
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
|
||||
color:{MUTED};">{intro}</p>
|
||||
{button}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
|
||||
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
|
||||
{note}
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _send(message: EmailMessage) -> None:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||
smtp.starttls()
|
||||
if settings.smtp_username:
|
||||
smtp.login(settings.smtp_username, settings.smtp_password)
|
||||
smtp.send_message(message)
|
||||
|
||||
|
||||
def _branded_mail(
|
||||
subject: str,
|
||||
to_email: str,
|
||||
headline: str,
|
||||
intro: str,
|
||||
button_label: str,
|
||||
button_url: str,
|
||||
note: str,
|
||||
) -> EmailMessage:
|
||||
"""Text + branded HTML alternative; the link is a real button."""
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["To"] = to_email
|
||||
message["From"] = settings.mail_from or settings.contact_to or to_email
|
||||
if settings.contact_to:
|
||||
message["Reply-To"] = settings.contact_to
|
||||
message.set_content(f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n")
|
||||
# Email-safe HTML: tables + inline CSS; some clients strip <style>, so
|
||||
# the palette is inlined via the f-string constants above.
|
||||
message.add_alternative(_page(headline, intro, button_label, button_url, note), subtype="html")
|
||||
return message
|
||||
|
||||
|
||||
def send_magic_link(to_email: str, link: str) -> bool:
|
||||
"""Mail the login link. Returns False (logs instead) when SMTP is unset.
|
||||
|
||||
Never raises: callers invoke it after committing (approve/bootstrap),
|
||||
so a delivery failure must not turn into a 500 — the token stays valid
|
||||
and the admin sees ``email_sent: false``.
|
||||
|
||||
The plaintext link only ever appears here: service/api logging must
|
||||
never include tokens or session ids (see test_auth no-leak test).
|
||||
"""
|
||||
if not settings.smtp_host:
|
||||
# Never log the link itself: without SMTP this fallback fires in
|
||||
# every environment, and logs are rarely as protected as mail.
|
||||
logger.info("SMTP not configured; dropping magic link for %s", to_email)
|
||||
return False
|
||||
message = _branded_mail(
|
||||
subject="Dein Login-Link für Kirche Einfach",
|
||||
to_email=to_email,
|
||||
headline="Anmeldung bei Kirche Einfach",
|
||||
intro=(
|
||||
"Klicke auf den Button, um dich anzumelden. Der Link ist "
|
||||
"15 Minuten gültig und funktioniert nur einmal."
|
||||
),
|
||||
button_label="Jetzt anmelden",
|
||||
button_url=link,
|
||||
note=(
|
||||
"Falls du den Button nicht nutzen kannst, kopiere den Link "
|
||||
"aus der Textversion dieser E-Mail in deinen Browser. "
|
||||
"Diese E-Mail wurde angefordert — wenn du das warst, ist "
|
||||
"alles gut; sonst kannst du sie ignorieren."
|
||||
),
|
||||
)
|
||||
try:
|
||||
_send(message)
|
||||
except (OSError, smtplib.SMTPException):
|
||||
logger.warning("magic-link delivery failed for %s", to_email)
|
||||
return False
|
||||
logger.info("magic link mailed to %s", to_email)
|
||||
return True
|
||||
|
||||
|
||||
def send_access_notification(request_id: str, name: str, email: str) -> bool:
|
||||
"""Notify CONTACT_TO about a new access request (best effort)."""
|
||||
if not settings.smtp_host or not settings.contact_to:
|
||||
logger.info("new access request %s from %s <%s>", request_id, name, email)
|
||||
return False
|
||||
message = _branded_mail(
|
||||
subject=f"Neue Zugangsanfrage: {name}",
|
||||
to_email=settings.contact_to,
|
||||
headline="Neue Zugangsanfrage",
|
||||
intro=(
|
||||
f"{name} ({email}) hat Zugang angefordert und wartet auf Freigabe in der Verwaltung."
|
||||
),
|
||||
button_label="In der Verwaltung prüfen",
|
||||
button_url=f"{settings.frontend_url.rstrip('/')}/app/admin",
|
||||
note="Diese Benachrichtigung geht an das Kontakt-Postfach.",
|
||||
)
|
||||
try:
|
||||
_send(message)
|
||||
except (OSError, smtplib.SMTPException):
|
||||
logger.warning("access-request notification failed for %s", request_id)
|
||||
return False
|
||||
return True
|
||||
58
backend/app/auth/schemas.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Pydantic contracts for the access/auth API."""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AccessRequestCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
reason: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class AccessRequestRead(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
email: str
|
||||
reason: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
decided_at: datetime | None
|
||||
decided_by: str | None
|
||||
|
||||
|
||||
class AccessDecisionRead(BaseModel):
|
||||
request: AccessRequestRead
|
||||
# False when SMTP is unset (dev): the magic link is logged, not mailed.
|
||||
email_sent: bool
|
||||
|
||||
|
||||
class MagicConsumeRequest(BaseModel):
|
||||
token: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class MagicRequestRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
|
||||
|
||||
class MagicRequestRead(BaseModel):
|
||||
message: str
|
||||
email_sent: bool
|
||||
|
||||
|
||||
class MeRead(BaseModel):
|
||||
id: UUID
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
tenant_id: str
|
||||
|
||||
|
||||
class AdminBootstrapRequest(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
|
||||
|
||||
class MessageRead(BaseModel):
|
||||
message: str
|
||||
306
backend/app/auth/service.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"""Access requests, magic-link tokens, and sessions (DB via function args).
|
||||
|
||||
Keep API handlers thin: all persistence lives here. Tokens and session ids
|
||||
are bearer secrets — log user/request ids only, never secret values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Session, func, select
|
||||
|
||||
from app.models import AccessRequest, MagicLinkToken, User, UserSession
|
||||
from app.settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAGIC_LINK_TTL = timedelta(minutes=15)
|
||||
SESSION_TTL = timedelta(days=30)
|
||||
|
||||
# Public request endpoint: at most this many requests per email per hour.
|
||||
REQUESTS_PER_EMAIL_PER_HOUR = 5
|
||||
# Same for magic-link requests (email bombing / token spam protection).
|
||||
MAGIC_LINKS_PER_EMAIL_PER_HOUR = 5
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotFoundError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class ConflictError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class ForbiddenError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidTokenError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class ExpiredTokenError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class UsedTokenError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
class RateLimitedError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def is_admin_email(email: str) -> bool:
|
||||
return normalize_email(email) in settings.admin_email_list
|
||||
|
||||
|
||||
def role_for(email: str) -> str:
|
||||
return "admin" if is_admin_email(email) else "member"
|
||||
|
||||
|
||||
def get_user(session: Session, user_id: UUID) -> User | None:
|
||||
return session.get(User, user_id)
|
||||
|
||||
|
||||
def get_user_by_email(session: Session, email: str) -> User | None:
|
||||
return session.exec(select(User).where(User.email == normalize_email(email))).first()
|
||||
|
||||
|
||||
def user_count(session: Session) -> int:
|
||||
return session.exec(select(func.count()).select_from(User)).one()
|
||||
|
||||
|
||||
def recent_request_count(session: Session, email: str, hours: int = 1) -> int:
|
||||
since = utcnow() - timedelta(hours=hours)
|
||||
return session.exec(
|
||||
select(func.count())
|
||||
.select_from(AccessRequest)
|
||||
.where(
|
||||
AccessRequest.email == normalize_email(email),
|
||||
AccessRequest.created_at >= since,
|
||||
)
|
||||
).one()
|
||||
|
||||
|
||||
def create_access_request(
|
||||
session: Session, name: str, email: str, reason: str = ""
|
||||
) -> AccessRequest:
|
||||
email = normalize_email(email)
|
||||
if recent_request_count(session, email) >= REQUESTS_PER_EMAIL_PER_HOUR:
|
||||
raise RateLimitedError("too many requests from this email, try again later")
|
||||
request = AccessRequest(name=name.strip(), email=email, reason=reason.strip())
|
||||
session.add(request)
|
||||
session.commit()
|
||||
session.refresh(request)
|
||||
logger.info("access request %s from %s", request.id, email)
|
||||
return request
|
||||
|
||||
|
||||
def get_access_request(session: Session, request_id: UUID) -> AccessRequest:
|
||||
request = session.get(AccessRequest, request_id)
|
||||
if request is None:
|
||||
raise NotFoundError(f"access request {request_id} not found")
|
||||
return request
|
||||
|
||||
|
||||
def list_access_requests(session: Session, status: str | None = None) -> list[AccessRequest]:
|
||||
query = select(AccessRequest).order_by(AccessRequest.created_at.desc())
|
||||
if status is not None:
|
||||
query = query.where(AccessRequest.status == status)
|
||||
return list(session.exec(query).all())
|
||||
|
||||
|
||||
def _get_or_create_user(session: Session, name: str, email: str) -> User:
|
||||
user = get_user_by_email(session, email)
|
||||
if user is None:
|
||||
user = User(name=name.strip(), email=email, role=role_for(email))
|
||||
session.add(user)
|
||||
session.flush()
|
||||
logger.info("user %s created for %s", user.id, email)
|
||||
else:
|
||||
if not user.name and name.strip():
|
||||
user.name = name.strip()
|
||||
new_role = role_for(email)
|
||||
if user.role != new_role:
|
||||
user.role = new_role
|
||||
session.add(user)
|
||||
session.flush()
|
||||
return user
|
||||
|
||||
|
||||
def issue_magic_token(session: Session, user: User) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
session.add(
|
||||
MagicLinkToken(
|
||||
user_id=user.id,
|
||||
token_hash=hash_token(token),
|
||||
expires_at=utcnow() + MAGIC_LINK_TTL,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
logger.info("magic token issued for user %s", user.id)
|
||||
return token
|
||||
|
||||
|
||||
def recent_token_count(session: Session, user: User, hours: int = 1) -> int:
|
||||
since = utcnow() - timedelta(hours=hours)
|
||||
return session.exec(
|
||||
select(func.count())
|
||||
.select_from(MagicLinkToken)
|
||||
.where(
|
||||
MagicLinkToken.user_id == user.id,
|
||||
MagicLinkToken.created_at >= since,
|
||||
)
|
||||
).one()
|
||||
|
||||
|
||||
def request_magic_link(session: Session, email: str) -> tuple[User, str]:
|
||||
"""Self-service login link for an allowed email.
|
||||
|
||||
Allowed = approved before (has a User row) or listed in ADMIN_EMAILS
|
||||
(admin user is created on first request). Anything else raises
|
||||
ForbiddenError — the frontend forwards those to the access form.
|
||||
Note: 403-vs-200 distinguishes registered emails by design here.
|
||||
"""
|
||||
email = normalize_email(email)
|
||||
user = get_user_by_email(session, email)
|
||||
if user is None:
|
||||
if not is_admin_email(email):
|
||||
raise ForbiddenError("email is not allowed to log in")
|
||||
user = _get_or_create_user(session, "", email)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
if recent_token_count(session, user) >= MAGIC_LINKS_PER_EMAIL_PER_HOUR:
|
||||
raise RateLimitedError("too many login links, try again later")
|
||||
return user, issue_magic_token(session, user)
|
||||
|
||||
|
||||
def approve_access_request(
|
||||
session: Session, request_id: UUID, decided_by: str
|
||||
) -> tuple[AccessRequest, User, str]:
|
||||
"""Approve → upsert User + fresh magic-link token (plaintext, mail it)."""
|
||||
request = get_access_request(session, request_id)
|
||||
if request.status != "pending":
|
||||
raise ConflictError(f"access request {request_id} already {request.status}")
|
||||
user = _get_or_create_user(session, request.name, request.email)
|
||||
request.status = "approved"
|
||||
request.decided_at = utcnow()
|
||||
request.decided_by = normalize_email(decided_by)
|
||||
session.add(request)
|
||||
session.commit()
|
||||
session.refresh(request)
|
||||
token = issue_magic_token(session, user)
|
||||
logger.info(
|
||||
"access request %s approved by %s; user %s",
|
||||
request.id,
|
||||
request.decided_by,
|
||||
user.id,
|
||||
)
|
||||
return request, user, token
|
||||
|
||||
|
||||
def reject_access_request(session: Session, request_id: UUID, decided_by: str) -> AccessRequest:
|
||||
request = get_access_request(session, request_id)
|
||||
if request.status != "pending":
|
||||
raise ConflictError(f"access request {request_id} already {request.status}")
|
||||
request.status = "rejected"
|
||||
request.decided_at = utcnow()
|
||||
request.decided_by = normalize_email(decided_by)
|
||||
session.add(request)
|
||||
session.commit()
|
||||
session.refresh(request)
|
||||
logger.info("access request %s rejected by %s", request.id, request.decided_by)
|
||||
return request
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
# SQLite returns naive datetimes; stored values are UTC.
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
def consume_magic_token(session: Session, token: str) -> UserSession:
|
||||
"""Single-use consume → session row. used_at is set before creation."""
|
||||
record = session.exec(
|
||||
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
|
||||
).first()
|
||||
if record is None:
|
||||
raise InvalidTokenError("unknown token")
|
||||
if record.used_at is not None:
|
||||
raise UsedTokenError("token already used")
|
||||
if _aware(record.expires_at) <= utcnow():
|
||||
raise ExpiredTokenError("token expired")
|
||||
user = session.get(User, record.user_id)
|
||||
if user is None:
|
||||
raise InvalidTokenError("unknown token")
|
||||
# Mark used first: a reused token can never mint a second session.
|
||||
record.used_at = utcnow()
|
||||
session.add(record)
|
||||
user_session = UserSession(
|
||||
id=secrets.token_urlsafe(32),
|
||||
user_id=user.id,
|
||||
expires_at=utcnow() + SESSION_TTL,
|
||||
)
|
||||
session.add(user_session)
|
||||
session.commit()
|
||||
session.refresh(user_session)
|
||||
logger.info("magic token consumed for user %s", user.id)
|
||||
return user_session
|
||||
|
||||
|
||||
def get_valid_session(session: Session, session_id: str) -> UserSession | None:
|
||||
record = session.get(UserSession, session_id)
|
||||
if record is None or record.revoked_at is not None:
|
||||
return None
|
||||
if _aware(record.expires_at) <= utcnow():
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def revoke_session(session: Session, session_id: str) -> None:
|
||||
record = session.get(UserSession, session_id)
|
||||
if record is not None and record.revoked_at is None:
|
||||
record.revoked_at = utcnow()
|
||||
session.add(record)
|
||||
session.commit()
|
||||
|
||||
|
||||
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:
|
||||
"""First-admin setup: only when the user table is empty AND the email
|
||||
is in ADMIN_EMAILS. Issues a magic-link token like approve does."""
|
||||
email = normalize_email(email)
|
||||
if user_count(session) > 0:
|
||||
raise ConflictError("users already exist, bootstrap is closed")
|
||||
if not is_admin_email(email):
|
||||
raise ForbiddenError("email is not an admin email")
|
||||
user = User(name="", email=email, role="admin")
|
||||
session.add(user)
|
||||
session.flush()
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
token = issue_magic_token(session, user)
|
||||
logger.info("admin bootstrapped for user %s", user.id)
|
||||
return user, token
|
||||
|
|
@ -1,23 +1,35 @@
|
|||
"""Flows + approvals HTTP API."""
|
||||
"""Flows + approvals HTTP API.
|
||||
|
||||
Auth model: flow *management* (create/version/deploy/sync/import) is
|
||||
admin-only. Browsing (list/get/input-schema) is public so the website
|
||||
renders. Runs, run reads and approval reads require login and are scoped
|
||||
to the session tenant. Flow *callbacks* (LFX has no session) carry a
|
||||
shared X-Callback-Token instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth.deps import get_current_user, require_admin
|
||||
from app.db import get_engine, get_session
|
||||
from app.models import User
|
||||
from app.settings import settings
|
||||
|
||||
from . import input_schema as form_schema
|
||||
from . import runner, store
|
||||
from .deploy import import_from_langflow
|
||||
from .sync import lfx_id_for, sync_from_directory
|
||||
from .langflow_client import (
|
||||
LangflowClient,
|
||||
LangflowError,
|
||||
|
|
@ -30,7 +42,6 @@ from .lfx_client import (
|
|||
LfxError,
|
||||
LfxInvalidFlow,
|
||||
LfxNotFound,
|
||||
LfxRunFailed,
|
||||
LfxUnavailable,
|
||||
)
|
||||
from .schemas import (
|
||||
|
|
@ -44,14 +55,18 @@ from .schemas import (
|
|||
FlowImportRequest,
|
||||
FlowVersionCreate,
|
||||
FlowVersionRead,
|
||||
InputSchemaRead,
|
||||
RunRead,
|
||||
RunRequest,
|
||||
SyncReport,
|
||||
)
|
||||
from .sync import lfx_id_for, sync_from_directory
|
||||
|
||||
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
|
||||
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
yield from get_session()
|
||||
|
|
@ -62,9 +77,7 @@ def get_lfx() -> LfxClient:
|
|||
|
||||
|
||||
def get_langflow() -> LangflowClient:
|
||||
return LangflowClient(
|
||||
settings.langflow_url, api_key=settings.langflow_api_key or ""
|
||||
)
|
||||
return LangflowClient(settings.langflow_url, api_key=settings.langflow_api_key or "")
|
||||
|
||||
|
||||
def _read_definition(d) -> FlowDefinitionRead:
|
||||
|
|
@ -89,6 +102,7 @@ def _read_version(v) -> FlowVersionRead:
|
|||
deployed_at=v.deployed_at,
|
||||
author=v.author,
|
||||
created_at=v.created_at,
|
||||
input_schema=v.input_schema,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -124,7 +138,11 @@ def _read_task(t) -> ApprovalRead:
|
|||
|
||||
|
||||
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
|
||||
def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)):
|
||||
def create_flow(
|
||||
data: FlowDefinitionCreate,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
_admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
try:
|
||||
return _read_definition(store.create_definition(session, data))
|
||||
except store.ConflictError as exc:
|
||||
|
|
@ -134,14 +152,15 @@ def create_flow(data: FlowDefinitionCreate, session: Session = Depends(get_db)):
|
|||
|
||||
|
||||
@flows_router.get("", response_model=list[FlowDefinitionRead])
|
||||
def list_flows(session: Session = Depends(get_db)):
|
||||
def list_flows(session: Annotated[Session, Depends(get_db)]):
|
||||
return [_read_definition(d) for d in store.list_definitions(session)]
|
||||
|
||||
|
||||
@flows_router.post("/sync", response_model=SyncReport)
|
||||
async def sync_flows(
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
_admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
|
||||
try:
|
||||
|
|
@ -151,18 +170,19 @@ async def sync_flows(
|
|||
|
||||
|
||||
@flows_router.get("/{flow_id}", response_model=FlowDefinitionRead)
|
||||
def get_flow(flow_id: UUID, session: Session = Depends(get_db)):
|
||||
def get_flow(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
|
||||
try:
|
||||
return _read_definition(store.get_definition(session, flow_id))
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@flows_router.post(
|
||||
"/{flow_id}/versions", response_model=FlowVersionRead, status_code=201
|
||||
)
|
||||
@flows_router.post("/{flow_id}/versions", response_model=FlowVersionRead, status_code=201)
|
||||
def create_version(
|
||||
flow_id: UUID, data: FlowVersionCreate, session: Session = Depends(get_db)
|
||||
flow_id: UUID,
|
||||
data: FlowVersionCreate,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
_admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
try:
|
||||
return _read_version(store.create_version(session, flow_id, data))
|
||||
|
|
@ -173,21 +193,24 @@ def create_version(
|
|||
|
||||
|
||||
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
|
||||
def list_versions(flow_id: UUID, session: Session = Depends(get_db)):
|
||||
def list_versions(
|
||||
flow_id: UUID,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
_admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
try:
|
||||
return [_read_version(v) for v in store.list_versions(session, flow_id)]
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@flows_router.post(
|
||||
"/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead
|
||||
)
|
||||
@flows_router.post("/{flow_id}/versions/{version_no}/deploy", response_model=FlowVersionRead)
|
||||
async def deploy_version(
|
||||
flow_id: UUID,
|
||||
version_no: int,
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
_admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
try:
|
||||
version = store.get_version_by_no(session, flow_id, version_no)
|
||||
|
|
@ -206,9 +229,11 @@ async def deploy_version(
|
|||
except LfxConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except LfxUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
logger.warning("lfx unavailable: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
|
||||
except LfxError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
logger.warning("lfx error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
||||
return _read_version(store.mark_deployed(session, version, uploaded.id))
|
||||
|
||||
|
||||
|
|
@ -222,55 +247,64 @@ def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
|
|||
async def run_flow(
|
||||
flow_id: UUID,
|
||||
data: RunRequest,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
stream: bool = Query(default=False),
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
):
|
||||
# Tenant, callback URL and continuation always come from the server:
|
||||
# the session, the settings, and the admin-authored definition.
|
||||
tenant_id = user.tenant_id
|
||||
try:
|
||||
definition = store.get_definition(session, flow_id)
|
||||
version = _resolve_version(session, flow_id, data.version_no)
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except store.InvalidError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if stream:
|
||||
return await _run_stream(session, lfx, version, data)
|
||||
return await _run_stream(session, lfx, version, data, user, definition)
|
||||
try:
|
||||
run = await runner.execute_run(
|
||||
session,
|
||||
lfx,
|
||||
version,
|
||||
payload=data.input,
|
||||
tenant_id=data.tenant_id,
|
||||
tenant_id=tenant_id,
|
||||
session_id=data.session_id,
|
||||
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
||||
continuation_flow_id=data.continuation_flow_id,
|
||||
callback_base_url=settings.callback_base_url,
|
||||
continuation_flow_id=definition.continuation_flow_id,
|
||||
callback_token=settings.callback_token,
|
||||
)
|
||||
except LfxNotFound as exc:
|
||||
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
||||
except LfxUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
logger.warning("lfx unavailable: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
|
||||
except LfxError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
logger.warning("lfx error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
||||
return _read_run(run)
|
||||
|
||||
|
||||
async def _run_stream(session: Session, lfx: LfxClient, version, data: RunRequest):
|
||||
async def _run_stream(
|
||||
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
|
||||
):
|
||||
if not version.lfx_flow_id:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"version {version.id} is not deployed to LFX"
|
||||
)
|
||||
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
|
||||
run = store.create_run(
|
||||
session,
|
||||
version,
|
||||
input=data.input,
|
||||
tenant_id=data.tenant_id,
|
||||
tenant_id=user.tenant_id,
|
||||
session_id=data.session_id,
|
||||
)
|
||||
envelope = runner.build_envelope(
|
||||
payload=data.input,
|
||||
run_id=run.id,
|
||||
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
||||
continuation_flow_id=data.continuation_flow_id,
|
||||
callback_base_url=settings.callback_base_url,
|
||||
continuation_flow_id=definition.continuation_flow_id,
|
||||
callback_token=settings.callback_token,
|
||||
)
|
||||
run_id = run.id
|
||||
lfx_flow_id = version.lfx_flow_id
|
||||
|
|
@ -279,30 +313,28 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
|
|||
async def event_gen() -> AsyncIterator[str]:
|
||||
yield f"event: run\ndata: {json.dumps({'run_id': str(run_id)})}\n\n"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
|
||||
) as client:
|
||||
async with client.stream(
|
||||
async with (
|
||||
httpx.AsyncClient(
|
||||
base_url=lfx.base_url, timeout=lfx.timeout, headers=lfx._headers()
|
||||
) as client,
|
||||
client.stream(
|
||||
"POST",
|
||||
f"/flows/{lfx_flow_id}/stream",
|
||||
json={"input_value": envelope, "session_id": session_id},
|
||||
) as response:
|
||||
if response.status_code == 404:
|
||||
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
|
||||
response.raise_for_status()
|
||||
async for chunk in response.aiter_text():
|
||||
yield chunk
|
||||
) as response,
|
||||
):
|
||||
if response.status_code == 404:
|
||||
raise LfxNotFound(f"flow {lfx_flow_id} unknown to LFX")
|
||||
response.raise_for_status()
|
||||
async for chunk in response.aiter_text():
|
||||
yield chunk
|
||||
except (LfxNotFound, httpx.HTTPError) as exc:
|
||||
yield f"event: error\ndata: {json.dumps({'error': str(exc)})}\n\n"
|
||||
# Finalize from a fresh session: the request session may be closed.
|
||||
with Session(get_engine()) as fresh:
|
||||
try:
|
||||
fresh_run = store.get_run(fresh, run_id)
|
||||
status = (
|
||||
"pending_approval"
|
||||
if runner._task_exists(fresh, run_id)
|
||||
else "completed"
|
||||
)
|
||||
status = "pending_approval" if runner._task_exists(fresh, run_id) else "completed"
|
||||
store.set_run_result(fresh, fresh_run, status, {"streamed": True})
|
||||
except store.NotFoundError:
|
||||
status = "failed"
|
||||
|
|
@ -314,32 +346,39 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
|
|||
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
|
||||
def list_runs(
|
||||
flow_id: UUID,
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
# Strict tenant scoping: the session decides, query params cannot widen.
|
||||
try:
|
||||
return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)]
|
||||
return [_read_run(r) for r in store.list_runs(session, flow_id, user.tenant_id)]
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@flows_router.get("/langflow/available")
|
||||
async def list_langflow_flows(langflow: LangflowClient = Depends(get_langflow)):
|
||||
async def list_langflow_flows(
|
||||
langflow: Annotated[LangflowClient, Depends(get_langflow)],
|
||||
_admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Browse flows on the Langflow authoring server (admin import source)."""
|
||||
try:
|
||||
return await langflow.list_flows()
|
||||
except LangflowUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
logger.warning("langflow unavailable: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
|
||||
except LangflowError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
logger.warning("langflow error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
||||
|
||||
|
||||
@flows_router.post("/import", response_model=FlowImportRead, status_code=201)
|
||||
async def import_flow(
|
||||
data: FlowImportRequest,
|
||||
session: Session = Depends(get_db),
|
||||
langflow: LangflowClient = Depends(get_langflow),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
langflow: Annotated[LangflowClient, Depends(get_langflow)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
_admin: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
|
||||
try:
|
||||
|
|
@ -355,9 +394,11 @@ async def import_flow(
|
|||
except LangflowNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except LangflowUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
logger.warning("langflow unavailable: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="authoring server unavailable") from exc
|
||||
except LangflowError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
logger.warning("langflow error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="authoring server error") from exc
|
||||
except store.ConflictError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except store.InvalidError as exc:
|
||||
|
|
@ -367,33 +408,70 @@ async def import_flow(
|
|||
except LfxConflict as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except LfxUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
logger.warning("lfx unavailable: %s", exc)
|
||||
raise HTTPException(status_code=503, detail="flow runtime unavailable") from exc
|
||||
except LfxError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return FlowImportRead(
|
||||
definition=_read_definition(definition), version=_read_version(version)
|
||||
logger.warning("lfx error: %s", exc)
|
||||
raise HTTPException(status_code=502, detail="flow runtime error") from exc
|
||||
return FlowImportRead(definition=_read_definition(definition), version=_read_version(version))
|
||||
|
||||
|
||||
@flows_router.get("/{flow_id}/input-schema", response_model=InputSchemaRead)
|
||||
def get_input_schema(
|
||||
flow_id: UUID,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
):
|
||||
"""Form schema of the latest deployed version (404 when none)."""
|
||||
try:
|
||||
version = store.get_latest_deployed_version(session, flow_id)
|
||||
except (store.NotFoundError, store.InvalidError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
|
||||
return InputSchemaRead(
|
||||
flow_id=flow_id,
|
||||
version_no=version.version_no,
|
||||
json_schema=cached.get("json_schema", {}),
|
||||
ui_schema=cached.get("ui_schema", {}),
|
||||
fallback=cached.get("fallback"),
|
||||
)
|
||||
|
||||
|
||||
@flows_router.get("/runs/{run_id}", response_model=RunRead)
|
||||
def get_run(
|
||||
run_id: UUID,
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
|
||||
"""Read a run. Other tenants' runs 404 (strict session scoping)."""
|
||||
try:
|
||||
run = store.get_run(session, run_id)
|
||||
except store.NotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
if tenant_id is not None and run.tenant_id != tenant_id:
|
||||
if run.tenant_id != user.tenant_id:
|
||||
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
||||
return _read_run(run)
|
||||
|
||||
|
||||
CALLBACK_TOKEN_HEADER = "X-Callback-Token"
|
||||
|
||||
|
||||
def _check_callback_token(token: str | None) -> None:
|
||||
"""LFX-served flows prove they carry our run envelope (no session there)."""
|
||||
expected = settings.callback_token
|
||||
if not expected:
|
||||
raise HTTPException(status_code=503, detail="flow callbacks are disabled")
|
||||
if not token or not secrets.compare_digest(token, expected):
|
||||
raise HTTPException(status_code=401, detail="invalid callback token")
|
||||
|
||||
|
||||
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
|
||||
def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
|
||||
def create_approval(
|
||||
data: ApprovalCreate,
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
callback_token: Annotated[str | None, Header(alias=CALLBACK_TOKEN_HEADER)] = None,
|
||||
):
|
||||
"""Flow callback contract: report a terminal approval state for a run."""
|
||||
_check_callback_token(callback_token)
|
||||
try:
|
||||
return _read_task(store.create_approval_task(session, data))
|
||||
except store.NotFoundError as exc:
|
||||
|
|
@ -406,20 +484,25 @@ def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)):
|
|||
|
||||
@approvals_router.get("", response_model=list[ApprovalRead])
|
||||
def list_approvals(
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_db),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)]
|
||||
return [_read_task(t) for t in store.list_approval_tasks(session, user.tenant_id)]
|
||||
|
||||
|
||||
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
|
||||
async def decide_approval(
|
||||
task_id: UUID,
|
||||
data: DecisionRequest,
|
||||
session: Session = Depends(get_db),
|
||||
lfx: LfxClient = Depends(get_lfx),
|
||||
session: Annotated[Session, Depends(get_db)],
|
||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
"""Decide an open task. Approve starts the continuation flow (if any)."""
|
||||
"""Decide an open task. Approve starts the continuation flow (if any).
|
||||
|
||||
Identity comes from the session: the approver is the logged-in user,
|
||||
and the continuation is the task's own (never client-chosen).
|
||||
"""
|
||||
try:
|
||||
task = store.get_task(session, task_id)
|
||||
except store.NotFoundError as exc:
|
||||
|
|
@ -439,7 +522,7 @@ async def decide_approval(
|
|||
session,
|
||||
task,
|
||||
decision="approved" if data.decision == "approve" else "rejected",
|
||||
approver=data.approver,
|
||||
approver=user.email,
|
||||
note=data.note,
|
||||
)
|
||||
except store.ConflictError as exc:
|
||||
|
|
@ -461,7 +544,8 @@ async def decide_approval(
|
|||
payload=payload,
|
||||
tenant_id=task.tenant_id,
|
||||
session_id=f"continuation-of-{task.run_id}",
|
||||
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
||||
callback_base_url=settings.callback_base_url,
|
||||
callback_token=settings.callback_token,
|
||||
)
|
||||
except LfxNotFound as exc:
|
||||
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from sqlmodel import Session
|
|||
from app.models import FlowDefinition, FlowVersion
|
||||
from app.settings import settings
|
||||
|
||||
from . import export, store, sync
|
||||
from . import export, sync
|
||||
from .langflow_client import LangflowClient
|
||||
from .lfx_client import LfxClient
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,7 @@ def slugify(name: str) -> str:
|
|||
|
||||
def canonical_bytes(doc: dict) -> bytes:
|
||||
"""Deterministic serialization: stable bytes for stable git diffs."""
|
||||
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode(
|
||||
"utf-8"
|
||||
)
|
||||
return (json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def content_hash(doc: dict) -> str:
|
||||
|
|
@ -38,9 +36,7 @@ def flow_path(flows_dir: str | Path, stem: str) -> Path:
|
|||
return Path(flows_dir) / f"{stem}.json"
|
||||
|
||||
|
||||
def write_flow_file(
|
||||
flows_dir: str | Path, doc: dict, *, name: str | None = None
|
||||
) -> Path:
|
||||
def write_flow_file(flows_dir: str | Path, doc: dict, *, name: str | None = None) -> Path:
|
||||
"""Write a flow document to backend/flows atomically (tmp + rename).
|
||||
|
||||
The filename derives from `name` (or the document's own name). Returns
|
||||
|
|
@ -50,9 +46,7 @@ def write_flow_file(
|
|||
flows_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = slugify(name or doc.get("name") or "flow")
|
||||
target = flow_path(flows_dir, stem)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp"
|
||||
)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(flows_dir), prefix=f".{stem}-", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as tmp:
|
||||
tmp.write(canonical_bytes(doc))
|
||||
|
|
|
|||
152
backend/app/flows/input_schema.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Derive a web-form schema from a Langflow export.
|
||||
|
||||
Looks for ``CustomInput*`` nodes (``data.type`` or the inner
|
||||
``display_name`` starting with ``CustomInput`` — see
|
||||
``backend/components/kirche/form_inputs.py``) and maps their ``template``
|
||||
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
||||
|
||||
* ``name`` → property key, ``display_name`` → ``title``,
|
||||
``info`` → ``description``, ``value`` → ``default``,
|
||||
``options`` → ``enum``, ``required`` → required array
|
||||
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
|
||||
form fields, and are skipped
|
||||
* ``advanced`` fields render last via ``ui:order``
|
||||
* ``_input_type`` → JSON type + widget (unknown types fall back to string
|
||||
so the parser never crashes on new fields)
|
||||
|
||||
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
|
||||
renders one free-text box instead of a generated form.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _form_templates(langflow_json: dict) -> list[dict]:
|
||||
"""Inner node dicts (``data.node``) of all CustomInput* nodes."""
|
||||
if not isinstance(langflow_json, dict):
|
||||
return []
|
||||
data = langflow_json.get("data") or {}
|
||||
nodes = data.get("nodes") or []
|
||||
found = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_data = node.get("data") or {}
|
||||
node_type = str(node_data.get("type") or "")
|
||||
inner = node_data.get("node") or {}
|
||||
display = str(inner.get("display_name") or "")
|
||||
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
|
||||
found.append(inner)
|
||||
return found
|
||||
|
||||
|
||||
def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
|
||||
"""(JSON-Schema property, uiSchema entry) for one template field."""
|
||||
input_type = str(field.get("_input_type") or "")
|
||||
prop: dict = {}
|
||||
ui: dict = {}
|
||||
title = field.get("display_name") or name
|
||||
info = field.get("info") or ""
|
||||
if title:
|
||||
prop["title"] = title
|
||||
if info:
|
||||
prop["description"] = info
|
||||
|
||||
options = field.get("options") or []
|
||||
if input_type in ("StrInput", "MessageTextInput"):
|
||||
prop["type"] = "string"
|
||||
elif input_type == "MultilineInput":
|
||||
prop["type"] = "string"
|
||||
ui["ui:widget"] = "textarea"
|
||||
elif input_type == "SecretStrInput":
|
||||
prop["type"] = "string"
|
||||
ui["ui:widget"] = "password"
|
||||
elif input_type == "IntInput":
|
||||
prop["type"] = "integer"
|
||||
elif input_type == "FloatInput":
|
||||
prop["type"] = "number"
|
||||
elif input_type == "BoolInput":
|
||||
prop["type"] = "boolean"
|
||||
elif input_type == "DropdownInput":
|
||||
prop["type"] = "string"
|
||||
if options:
|
||||
prop["enum"] = list(options)
|
||||
elif input_type == "MultiselectInput":
|
||||
prop["type"] = "array"
|
||||
items: dict = {"type": "string"}
|
||||
if options:
|
||||
items["enum"] = list(options)
|
||||
prop["items"] = items
|
||||
prop["uniqueItems"] = True
|
||||
elif input_type == "SliderInput":
|
||||
prop["type"] = "number"
|
||||
spec = field.get("range_spec") or {}
|
||||
if spec.get("min") is not None:
|
||||
prop["minimum"] = spec["min"]
|
||||
if spec.get("max") is not None:
|
||||
prop["maximum"] = spec["max"]
|
||||
if spec.get("step"):
|
||||
prop["multipleOf"] = spec["step"]
|
||||
ui["ui:widget"] = "range"
|
||||
elif input_type == "FileInput":
|
||||
# Stub for now: string reference only, real upload comes later.
|
||||
prop["type"] = "string"
|
||||
else:
|
||||
prop["type"] = "string"
|
||||
|
||||
value = field.get("value")
|
||||
if value is not None:
|
||||
# Whitespace-only string values are the flow generator's sentinel
|
||||
# for "required, filled at runtime" (see make_flow_fixtures.py) —
|
||||
# not a real default for the web form.
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return prop, ui
|
||||
# A default outside its enum would fail validation on touch.
|
||||
enum = prop.get("enum")
|
||||
if not (enum is not None and value not in enum):
|
||||
# bool is an int subclass; keep integer defaults clean.
|
||||
if prop.get("type") == "integer" and isinstance(value, bool):
|
||||
pass
|
||||
else:
|
||||
prop["default"] = value
|
||||
return prop, ui
|
||||
|
||||
|
||||
def extract_form_schema(langflow_json: dict) -> dict:
|
||||
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
|
||||
templates = _form_templates(langflow_json)
|
||||
if not templates:
|
||||
return {
|
||||
"json_schema": {"type": "object", "properties": {}},
|
||||
"ui_schema": {},
|
||||
"fallback": "textarea",
|
||||
}
|
||||
template = templates[0].get("template") or {}
|
||||
properties: dict = {}
|
||||
required: list[str] = []
|
||||
ui_schema: dict = {}
|
||||
normal: list[str] = []
|
||||
advanced: list[str] = []
|
||||
for key, field in template.items():
|
||||
if key == "code" or not isinstance(field, dict):
|
||||
continue
|
||||
if field.get("show") is False:
|
||||
continue
|
||||
name = field.get("name") or key
|
||||
prop, ui = _field_schema(name, field)
|
||||
properties[name] = prop
|
||||
if field.get("required"):
|
||||
required.append(name)
|
||||
if ui:
|
||||
ui_schema[name] = ui
|
||||
(advanced if field.get("advanced") else normal).append(name)
|
||||
if advanced:
|
||||
ui_schema["ui:order"] = normal + advanced + ["*"]
|
||||
return {
|
||||
"json_schema": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
},
|
||||
"ui_schema": ui_schema,
|
||||
}
|
||||
|
|
@ -51,9 +51,7 @@ class LfxRunResult:
|
|||
class LfxClient:
|
||||
"""Thin async wrapper around the LFX serve HTTP API."""
|
||||
|
||||
def __init__(
|
||||
self, base_url: str, timeout: float = 120.0, api_key: str | None = None
|
||||
) -> None:
|
||||
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.api_key = api_key
|
||||
|
|
|
|||
|
|
@ -20,15 +20,15 @@ def build_envelope(
|
|||
run_id: UUID,
|
||||
callback_base_url: str,
|
||||
continuation_flow_id: UUID | None,
|
||||
callback_token: str,
|
||||
) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"payload": payload,
|
||||
"run_id": str(run_id),
|
||||
"callback_url": callback_base_url.rstrip("/"),
|
||||
"continuation_flow_id": (
|
||||
str(continuation_flow_id) if continuation_flow_id else None
|
||||
),
|
||||
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
||||
"callback_token": callback_token,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -36,9 +36,7 @@ def build_envelope(
|
|||
def _task_exists(session: Session, run_id: UUID) -> bool:
|
||||
return (
|
||||
session.exec(
|
||||
select(ApprovalTask).where(
|
||||
ApprovalTask.run_id == run_id, ApprovalTask.status == "open"
|
||||
)
|
||||
select(ApprovalTask).where(ApprovalTask.run_id == run_id, ApprovalTask.status == "open")
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
|
|
@ -54,6 +52,7 @@ async def execute_run(
|
|||
session_id: str | None = None,
|
||||
callback_base_url: str,
|
||||
continuation_flow_id: UUID | None = None,
|
||||
callback_token: str = "",
|
||||
) -> FlowRun:
|
||||
"""Run a deployed version to a terminal state (no interrupts).
|
||||
|
||||
|
|
@ -72,6 +71,7 @@ async def execute_run(
|
|||
run_id=run.id,
|
||||
callback_base_url=callback_base_url,
|
||||
continuation_flow_id=continuation_flow_id,
|
||||
callback_token=callback_token,
|
||||
)
|
||||
try:
|
||||
result = await lfx.run_flow(
|
||||
|
|
@ -80,9 +80,7 @@ async def execute_run(
|
|||
session_id=session_id or str(run.id),
|
||||
)
|
||||
except LfxRunFailed as exc:
|
||||
return store.set_run_result(
|
||||
session, run, "failed", {"error": str(exc)}
|
||||
)
|
||||
return store.set_run_result(session, run, "failed", {"error": str(exc)})
|
||||
output = {
|
||||
"result": result.result,
|
||||
"component": result.component,
|
||||
|
|
|
|||
|
|
@ -37,19 +37,31 @@ class FlowVersionRead(BaseModel):
|
|||
deployed_at: datetime | None
|
||||
author: str
|
||||
created_at: datetime
|
||||
input_schema: dict | None = None
|
||||
|
||||
|
||||
class InputSchemaRead(BaseModel):
|
||||
"""Form schema of the latest deployed version (RJSF renders it)."""
|
||||
|
||||
flow_id: UUID
|
||||
version_no: int
|
||||
json_schema: dict
|
||||
ui_schema: dict = Field(default_factory=dict)
|
||||
# Set when the flow has no CustomInput node: render a free-text box.
|
||||
fallback: str | None = None
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
"""Start a flow run. `input` is the domain payload handed to the flow."""
|
||||
"""Start a flow run. `input` is the domain payload handed to the flow.
|
||||
|
||||
Tenant, callback URL and continuation come from the server (session /
|
||||
settings / task), never from the caller — they must not be
|
||||
client-controllable (SSRF / cross-flow chaining).
|
||||
"""
|
||||
|
||||
input: dict = Field(default_factory=dict)
|
||||
version_no: int | None = None
|
||||
tenant_id: str = Field(default="default", max_length=200)
|
||||
session_id: str | None = None
|
||||
# Base URL the flow uses for callbacks (e.g. approval tasks).
|
||||
# Defaults to settings.callback_base_url; tests point it at a live server.
|
||||
callback_base_url: str | None = None
|
||||
continuation_flow_id: UUID | None = None
|
||||
|
||||
|
||||
class RunRead(BaseModel):
|
||||
|
|
@ -103,10 +115,10 @@ class FlowImportRead(BaseModel):
|
|||
|
||||
|
||||
class DecisionRequest(BaseModel):
|
||||
"""Decide a task. Identity comes from the session, not the body."""
|
||||
|
||||
decision: str = Field(pattern="^(approve|reject)$")
|
||||
approver: str = Field(default="", max_length=200)
|
||||
note: str = Field(default="", max_length=2000)
|
||||
callback_base_url: str | None = None
|
||||
|
||||
|
||||
class DecisionRead(BaseModel):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
|
@ -10,6 +10,7 @@ from sqlmodel import Session, func, select
|
|||
|
||||
from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
||||
|
||||
from . import input_schema as form_schema
|
||||
from .schemas import ApprovalCreate, FlowDefinitionCreate, FlowVersionCreate
|
||||
|
||||
|
||||
|
|
@ -26,9 +27,10 @@ class InvalidError(Exception):
|
|||
|
||||
|
||||
def create_definition(session: Session, data: FlowDefinitionCreate) -> FlowDefinition:
|
||||
if data.continuation_flow_id is not None and session.get(
|
||||
FlowDefinition, data.continuation_flow_id
|
||||
) is None:
|
||||
if (
|
||||
data.continuation_flow_id is not None
|
||||
and session.get(FlowDefinition, data.continuation_flow_id) is None
|
||||
):
|
||||
raise InvalidError("continuation_flow_id unknown")
|
||||
definition = FlowDefinition(
|
||||
name=data.name,
|
||||
|
|
@ -56,18 +58,14 @@ def list_definitions(session: Session) -> list[FlowDefinition]:
|
|||
return list(session.exec(select(FlowDefinition).order_by(FlowDefinition.name)).all())
|
||||
|
||||
|
||||
def get_definition_by_source_path(
|
||||
session: Session, source_path: str
|
||||
) -> FlowDefinition | None:
|
||||
def get_definition_by_source_path(session: Session, source_path: str) -> FlowDefinition | None:
|
||||
"""Find the definition synced from a given backend/flows filename."""
|
||||
return session.exec(
|
||||
select(FlowDefinition).where(FlowDefinition.source_path == source_path)
|
||||
).first()
|
||||
|
||||
|
||||
def set_definition_name(
|
||||
session: Session, definition: FlowDefinition, name: str
|
||||
) -> FlowDefinition:
|
||||
def set_definition_name(session: Session, definition: FlowDefinition, name: str) -> FlowDefinition:
|
||||
"""Rename a definition and record its source file. Name clashes 409."""
|
||||
definition.name = name
|
||||
session.add(definition)
|
||||
|
|
@ -87,32 +85,22 @@ def next_version_no(session: Session, flow_id: UUID) -> int:
|
|||
return (max_no or 0) + 1
|
||||
|
||||
|
||||
def delete_definition_cascade(
|
||||
session: Session, definition: FlowDefinition
|
||||
) -> dict[str, int]:
|
||||
def delete_definition_cascade(session: Session, definition: FlowDefinition) -> dict[str, int]:
|
||||
"""Remove a definition and its whole subtree (tasks, runs, versions).
|
||||
|
||||
Used when the canonical file disappears: the directory is the source of
|
||||
truth, so the derived registry follows. Commits once.
|
||||
"""
|
||||
version_ids = list(
|
||||
session.exec(
|
||||
select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)
|
||||
).all()
|
||||
session.exec(select(FlowVersion.id).where(FlowVersion.flow_id == definition.id)).all()
|
||||
)
|
||||
task_count = 0
|
||||
run_count = 0
|
||||
if version_ids:
|
||||
tasks = list(
|
||||
session.exec(
|
||||
select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))
|
||||
).all()
|
||||
)
|
||||
runs = list(
|
||||
session.exec(
|
||||
select(FlowRun).where(FlowRun.version_id.in_(version_ids))
|
||||
).all()
|
||||
session.exec(select(ApprovalTask).where(ApprovalTask.version_id.in_(version_ids))).all()
|
||||
)
|
||||
runs = list(session.exec(select(FlowRun).where(FlowRun.version_id.in_(version_ids))).all())
|
||||
task_count, run_count = len(tasks), len(runs)
|
||||
for row in (*tasks, *runs):
|
||||
session.delete(row)
|
||||
|
|
@ -141,9 +129,7 @@ def validate_flow_json(flow_json: dict) -> None:
|
|||
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
|
||||
|
||||
|
||||
def create_version(
|
||||
session: Session, flow_id: UUID, data: FlowVersionCreate
|
||||
) -> FlowVersion:
|
||||
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
|
||||
get_definition(session, flow_id)
|
||||
validate_flow_json(data.langflow_json)
|
||||
max_no = session.exec(
|
||||
|
|
@ -154,6 +140,9 @@ def create_version(
|
|||
version_no=(max_no or 0) + 1,
|
||||
langflow_json=data.langflow_json,
|
||||
author=data.author,
|
||||
# Single choke point: sync, import, and API creation all cache the
|
||||
# form schema here, so reads never re-parse.
|
||||
input_schema=form_schema.extract_form_schema(data.langflow_json),
|
||||
)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
|
|
@ -203,7 +192,7 @@ def get_latest_deployed_version(session: Session, flow_id: UUID) -> FlowVersion:
|
|||
|
||||
def mark_deployed(session: Session, version: FlowVersion, lfx_flow_id: str) -> FlowVersion:
|
||||
version.lfx_flow_id = lfx_flow_id
|
||||
version.deployed_at = datetime.now(timezone.utc)
|
||||
version.deployed_at = datetime.now(UTC)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
session.refresh(version)
|
||||
|
|
@ -230,12 +219,10 @@ def create_run(
|
|||
return run
|
||||
|
||||
|
||||
def set_run_result(
|
||||
session: Session, run: FlowRun, status: str, output: dict | None
|
||||
) -> FlowRun:
|
||||
def set_run_result(session: Session, run: FlowRun, status: str, output: dict | None) -> FlowRun:
|
||||
run.status = status
|
||||
run.output = output
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
run.finished_at = datetime.now(UTC)
|
||||
session.add(run)
|
||||
session.commit()
|
||||
session.refresh(run)
|
||||
|
|
@ -249,9 +236,7 @@ def get_run(session: Session, run_id: UUID) -> FlowRun:
|
|||
return run
|
||||
|
||||
|
||||
def list_runs(
|
||||
session: Session, flow_id: UUID, tenant_id: str | None = None
|
||||
) -> list[FlowRun]:
|
||||
def list_runs(session: Session, flow_id: UUID, tenant_id: str | None = None) -> list[FlowRun]:
|
||||
get_definition(session, flow_id)
|
||||
query = (
|
||||
select(FlowRun)
|
||||
|
|
@ -268,9 +253,10 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
|
|||
run = session.get(FlowRun, data.run_id)
|
||||
if run is None:
|
||||
raise NotFoundError(f"run {data.run_id} not found")
|
||||
if data.continuation_flow_id is not None and session.get(
|
||||
FlowDefinition, data.continuation_flow_id
|
||||
) is None:
|
||||
if (
|
||||
data.continuation_flow_id is not None
|
||||
and session.get(FlowDefinition, data.continuation_flow_id) is None
|
||||
):
|
||||
raise InvalidError("continuation_flow_id unknown")
|
||||
existing = session.exec(
|
||||
select(ApprovalTask).where(
|
||||
|
|
@ -289,7 +275,7 @@ def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask
|
|||
)
|
||||
session.add(task)
|
||||
run.status = "pending_approval"
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
run.finished_at = datetime.now(UTC)
|
||||
session.add(run)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
|
|
@ -326,7 +312,7 @@ def decide_task(
|
|||
task.status = decision
|
||||
task.approver = approver
|
||||
task.decision_note = note
|
||||
task.decided_at = datetime.now(timezone.utc)
|
||||
task.decided_at = datetime.now(UTC)
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,7 @@ def lfx_id_for(definition_id: UUID, version_no: int) -> str:
|
|||
Same (definition, version) always maps to the same id, so re-syncs and
|
||||
crash recovery overwrite instead of orphaning LFX registrations.
|
||||
"""
|
||||
return str(
|
||||
uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}")
|
||||
)
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"kirche-einfach/{definition_id}/v{version_no}"))
|
||||
|
||||
|
||||
def _latest_version(session: Session, flow_id: UUID) -> FlowVersion | None:
|
||||
|
|
@ -72,9 +70,7 @@ async def sync_document(
|
|||
with sync_lock:
|
||||
definition = store.get_definition_by_source_path(session, source_filename)
|
||||
if definition is None:
|
||||
definition = store.create_definition(
|
||||
session, FlowDefinitionCreate(name=name)
|
||||
)
|
||||
definition = store.create_definition(session, FlowDefinitionCreate(name=name))
|
||||
definition.source_path = source_filename
|
||||
session.add(definition)
|
||||
session.commit()
|
||||
|
|
@ -86,10 +82,8 @@ async def sync_document(
|
|||
store.set_definition_name(session, definition, name)
|
||||
|
||||
latest = _latest_version(session, definition.id)
|
||||
if (
|
||||
latest is not None
|
||||
and export.content_hash(latest.langflow_json)
|
||||
== export.content_hash(doc)
|
||||
if latest is not None and export.content_hash(latest.langflow_json) == export.content_hash(
|
||||
doc
|
||||
):
|
||||
return definition, latest, "skipped"
|
||||
|
||||
|
|
@ -110,15 +104,9 @@ async def sync_document(
|
|||
FlowVersionCreate(langflow_json=doc, author=author),
|
||||
)
|
||||
if version.version_no != version_no: # pragma: no cover - lock prevents this
|
||||
raise store.ConflictError(
|
||||
f"concurrent version write for flow {definition.id}"
|
||||
)
|
||||
raise store.ConflictError(f"concurrent version write for flow {definition.id}")
|
||||
version = store.mark_deployed(session, version, uploaded.id)
|
||||
if (
|
||||
latest is not None
|
||||
and latest.lfx_flow_id
|
||||
and latest.lfx_flow_id != uploaded.id
|
||||
):
|
||||
if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
|
||||
try:
|
||||
await lfx.delete_flow(latest.lfx_flow_id)
|
||||
except LfxError as exc:
|
||||
|
|
@ -175,10 +163,8 @@ async def sync_from_directory(
|
|||
report.errors[filename] = "top-level JSON must be an object"
|
||||
continue
|
||||
try:
|
||||
_, _, action = await sync_document(
|
||||
session, lfx, doc, source_filename=filename
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - reconciliation is total
|
||||
_, _, action = await sync_document(session, lfx, doc, source_filename=filename)
|
||||
except (store.InvalidError, store.ConflictError, store.NotFoundError, LfxError) as exc:
|
||||
report.errors[filename] = str(exc)
|
||||
continue
|
||||
getattr(report, action).append(path.stem)
|
||||
|
|
@ -193,7 +179,7 @@ async def sync_from_directory(
|
|||
continue
|
||||
try:
|
||||
await delete_definition_tree(session, lfx, definition)
|
||||
except Exception as exc: # noqa: BLE001 - reconciliation is total
|
||||
except store.NotFoundError as exc:
|
||||
report.errors[stale] = str(exc)
|
||||
continue
|
||||
report.deleted.append(Path(stale).stem)
|
||||
|
|
@ -205,9 +191,7 @@ def default_session_factory() -> Callable[[], Session]:
|
|||
|
||||
|
||||
def default_lfx_factory() -> Callable[[], LfxClient]:
|
||||
return lambda: LfxClient(
|
||||
settings.lfx_url, api_key=settings.lfx_api_key or None
|
||||
)
|
||||
return lambda: LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||
|
||||
|
||||
def run_sync_blocking(
|
||||
|
|
@ -246,14 +230,12 @@ async def watch_forever(
|
|||
logger.error("flows dir missing, retrying in 30s: %s", flows_dir)
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
pass
|
||||
if stop.is_set():
|
||||
return
|
||||
logger.info("watching %s", flows_dir)
|
||||
async for _ in awatch(
|
||||
flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100
|
||||
):
|
||||
async for _ in awatch(flows_dir, watch_filter=_JsonFilter(), debounce=1000, step=100):
|
||||
if stop.is_set():
|
||||
break
|
||||
try:
|
||||
|
|
@ -261,7 +243,7 @@ async def watch_forever(
|
|||
except FileNotFoundError as exc:
|
||||
logger.error("flow sync failed: %s", exc)
|
||||
continue
|
||||
except Exception: # noqa: BLE001 - watcher must survive
|
||||
except Exception:
|
||||
logger.exception("flow sync failed")
|
||||
continue
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ from contextlib import asynccontextmanager
|
|||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
|
||||
|
||||
from .auth.api import access_router, auth_router
|
||||
from .flows.api import approvals_router, flows_router
|
||||
from .flows.sync import run_sync_blocking, watch_forever
|
||||
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
|
||||
from .settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -29,8 +32,8 @@ def _blocking_startup_sync() -> None:
|
|||
logger.warning("startup flow sync: %s: %s", filename, reason)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("startup flow sync skipped: %s", exc)
|
||||
except Exception: # noqa: BLE001 - API must start even if sync fails
|
||||
logger.exception("startup flow sync failed")
|
||||
except Exception:
|
||||
logger.exception("startup flow sync failed") # never fatal at boot
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -49,7 +52,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception): # noqa: BLE001 - shutdown
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -57,14 +60,25 @@ app = FastAPI(
|
|||
title="Kirche Einfach API",
|
||||
description="KI-gestützte Verwaltungsplattform für Kirche",
|
||||
version="0.1.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
openapi_url="/api/openapi.json",
|
||||
# Interactive docs only in debug: /api/docs maps the whole surface.
|
||||
docs_url="/api/docs" if settings.debug else None,
|
||||
redoc_url="/api/redoc" if settings.debug else None,
|
||||
openapi_url="/api/openapi.json" if settings.debug else None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# TLS terminates at the reverse proxy: uvicorn --proxy-headers (see
|
||||
# entrypoint.sh) trusts its forwarding headers so Secure cookies, client
|
||||
# IPs (throttle) and absolute URLs are correct. The API is not directly
|
||||
# internet-reachable (proxy-only), so trusting them is safe.
|
||||
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes)
|
||||
app.add_middleware(RunThrottleMiddleware)
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
app.include_router(flows_router)
|
||||
app.include_router(approvals_router)
|
||||
app.include_router(access_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
|
|
|||
92
backend/app/middleware.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Edge hardening: run throttling, security headers.
|
||||
|
||||
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
|
||||
main.py). All limits are settings-driven so tests can shrink them. State
|
||||
is in-process memory: correct for the single-replica SQLite deployment;
|
||||
revisit with Redis if the API ever scales horizontally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
# POST .../run is the expensive endpoint (LFX execution, LLM cost):
|
||||
# per client IP and minute.
|
||||
RUN_PATH_SUFFIX = "/run"
|
||||
THROTTLE_WINDOW_SECONDS = 60.0
|
||||
|
||||
_hits: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def reset_throttle() -> None:
|
||||
"""Drop all counters (tests)."""
|
||||
_hits.clear()
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
# Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
|
||||
# scope["client"] from X-Forwarded-For.
|
||||
if request.client is not None:
|
||||
return request.client.host
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
return forwarded.split(",")[0].strip() or "unknown"
|
||||
|
||||
|
||||
class RunThrottleMiddleware:
|
||||
"""Per-IP sliding window on POST .../run (429 + Retry-After)."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if (
|
||||
scope["type"] != "http"
|
||||
or scope.get("method") != "POST"
|
||||
or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
|
||||
):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
request = Request(scope)
|
||||
now = time.monotonic()
|
||||
window_start = now - THROTTLE_WINDOW_SECONDS
|
||||
bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
|
||||
if len(bucket) >= settings.run_throttle_per_minute:
|
||||
retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
|
||||
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
|
||||
response.headers["Retry-After"] = str(retry_after)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
bucket.append(now)
|
||||
_hits[_client_ip(request)] = bucket
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Baseline response headers (TLS/HSTS live on the proxy)."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def secured(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
headers = MutableHeaders(raw=message.setdefault("headers", []))
|
||||
headers["X-Content-Type-Options"] = "nosniff"
|
||||
headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
headers["Content-Security-Policy"] = "frame-ancestors 'self'"
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, secured)
|
||||
|
|
@ -1,3 +1,13 @@
|
|||
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
|
||||
from .user import AccessRequest, MagicLinkToken, User, UserSession
|
||||
|
||||
__all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"]
|
||||
__all__ = [
|
||||
"AccessRequest",
|
||||
"ApprovalTask",
|
||||
"FlowDefinition",
|
||||
"FlowRun",
|
||||
"FlowVersion",
|
||||
"MagicLinkToken",
|
||||
"User",
|
||||
"UserSession",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Flow engine domain models (SQLModel tables)."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime, UniqueConstraint
|
||||
|
|
@ -8,7 +8,7 @@ from sqlmodel import Field, SQLModel
|
|||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _ts_column() -> Column:
|
||||
|
|
@ -33,9 +33,7 @@ class FlowDefinition(SQLModel, table=True):
|
|||
# (e.g. "simple_rule.json"). None for definitions created via the API.
|
||||
source_path: str | None = Field(default=None, max_length=255)
|
||||
# Flow to run after a human approval on this flow's runs (nullable).
|
||||
continuation_flow_id: UUID | None = Field(
|
||||
default=None, foreign_key="flow_definitions.id"
|
||||
)
|
||||
continuation_flow_id: UUID | None = Field(default=None, foreign_key="flow_definitions.id")
|
||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
||||
|
||||
|
||||
|
|
@ -54,6 +52,9 @@ class FlowVersion(SQLModel, table=True):
|
|||
deployed_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
||||
author: str = Field(default="", max_length=200)
|
||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
||||
# Cached form schema for this exact JSON (see app/flows/input_schema.py).
|
||||
# Computed at version creation; nullable for pre-Phase-3 rows.
|
||||
input_schema: dict | None = Field(default=None, sa_column=_json_column())
|
||||
|
||||
|
||||
class FlowRun(SQLModel, table=True):
|
||||
|
|
|
|||
72
backend/app/models/user.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Access-management domain models (SQLModel tables)."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import Column, DateTime
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _ts_column() -> Column:
|
||||
return Column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class User(SQLModel, table=True):
|
||||
"""A person with API access. Role is member or admin."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
email: str = Field(index=True, unique=True, max_length=320)
|
||||
name: str = Field(default="", max_length=200)
|
||||
# member | admin
|
||||
role: str = Field(default="member", max_length=20)
|
||||
tenant_id: str = Field(default="default", max_length=200)
|
||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
||||
|
||||
|
||||
class AccessRequest(SQLModel, table=True):
|
||||
"""A public request for access; an admin approves or rejects it."""
|
||||
|
||||
__tablename__ = "access_requests"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
name: str = Field(max_length=200)
|
||||
email: str = Field(index=True, max_length=320)
|
||||
reason: str = Field(default="", max_length=2000)
|
||||
# pending | approved | rejected
|
||||
status: str = Field(default="pending", index=True, max_length=20)
|
||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
||||
decided_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
||||
decided_by: str | None = Field(default=None, max_length=320)
|
||||
|
||||
|
||||
class MagicLinkToken(SQLModel, table=True):
|
||||
"""A single-use 15-minute login token. Only the sha256 is stored."""
|
||||
|
||||
__tablename__ = "magic_link_tokens"
|
||||
|
||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||
user_id: UUID = Field(foreign_key="users.id", index=True)
|
||||
token_hash: str = Field(index=True, unique=True, max_length=64)
|
||||
expires_at: datetime = Field(sa_column=_ts_column())
|
||||
# Set before the session row is created, so a reused token can never
|
||||
# mint a second session.
|
||||
used_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
||||
|
||||
|
||||
class UserSession(SQLModel, table=True):
|
||||
"""An opaque session id in the DB (cookie value). Revocable, expirable."""
|
||||
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
id: str = Field(primary_key=True, max_length=128)
|
||||
user_id: UUID = Field(foreign_key="users.id", index=True)
|
||||
created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column())
|
||||
expires_at: datetime = Field(sa_column=_ts_column())
|
||||
revoked_at: datetime | None = Field(default=None, sa_column=_ts_column())
|
||||
|
|
@ -14,7 +14,9 @@ class Settings(BaseSettings):
|
|||
debug: bool = False
|
||||
|
||||
# App database (SQLite file). Override via DATABASE_URL for other engines.
|
||||
database_url: str = str(REPO_ROOT / "backend" / "data" / "kirche.db")
|
||||
# Must be a URL (not a bare path): db.py and Alembic hand it to
|
||||
# create_engine, which rejects bare paths.
|
||||
database_url: str = f"sqlite:///{REPO_ROOT / 'backend' / 'data' / 'kirche.db'}"
|
||||
|
||||
# Canonical flow definitions (source of truth): one Langflow export per file.
|
||||
flows_dir: str = str(REPO_ROOT / "backend" / "flows")
|
||||
|
|
@ -29,6 +31,31 @@ class Settings(BaseSettings):
|
|||
# Base URL flows use for callbacks into this API. Must be reachable
|
||||
# FROM the LFX process (compose: http://api:8000).
|
||||
callback_base_url: str = "http://localhost:8000"
|
||||
# Shared secret that authenticates flow callbacks (POST /api/approvals).
|
||||
# The runner injects it into every run envelope; LFX-served flows send
|
||||
# it back as X-Callback-Token. Empty disables callbacks (503) — set a
|
||||
# long random value in prod (.env CALLBACK_TOKEN).
|
||||
callback_token: str = ""
|
||||
# Edge hardening (see app/middleware.py).
|
||||
max_body_bytes: int = 2 * 1024 * 1024
|
||||
run_throttle_per_minute: int = 60
|
||||
|
||||
# --- access management (magic-link auth, see app/auth) ---
|
||||
# Public frontend URL (magic links point at /login?token= there).
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
# Comma-separated emails that become admins (role assignment + bootstrap).
|
||||
admin_emails: str = ""
|
||||
# Reply-To on outgoing mail; also receives access-request notifications.
|
||||
contact_to: str = ""
|
||||
mail_from: str = ""
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_username: str = ""
|
||||
smtp_password: str = ""
|
||||
|
||||
@property
|
||||
def admin_email_list(self) -> list[str]:
|
||||
return [e.strip().lower() for e in self.admin_emails.split(",") if e.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
58
backend/components/kirche/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Authoring web forms (CustomInputForm)
|
||||
|
||||
Flows that should render a web form start with a `CustomInputForm` node
|
||||
(this package — the `kirche` category in the Langflow palette). The API
|
||||
derives a JSON Schema + uiSchema from the node's template
|
||||
(`app/flows/input_schema.py`) and the webapp renders it with RJSF.
|
||||
|
||||
## Adding a new form field
|
||||
|
||||
1. Open the Langflow authoring UI (http://localhost:7860) and load the
|
||||
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
|
||||
2. Edit the `CustomInputForm` node: every entry in its `inputs` list is
|
||||
one web-form field. Add an `lfx.io` input, e.g.
|
||||
```python
|
||||
from lfx.io import DropdownInput
|
||||
|
||||
DropdownInput(
|
||||
name="room", # JSON-Schema property key
|
||||
display_name="Room", # form label (title)
|
||||
info="Where should it take place", # help text (description)
|
||||
options=["Chapel", "Hall"], # dropdown choices (enum)
|
||||
value="Chapel", # prefill (default)
|
||||
required=True, # marks the field required
|
||||
)
|
||||
```
|
||||
Supported input types → widgets: `StrInput`/`MessageTextInput` → text,
|
||||
`MultilineInput` → textarea, `SecretStrInput` → password,
|
||||
`IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox,
|
||||
`DropdownInput` → select, `MultiselectInput` → multi-select,
|
||||
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
|
||||
(real upload later).
|
||||
3. Add the new field to `collect()` so it reaches the JSON output.
|
||||
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
|
||||
input, `show=False`). Submitted web-form values arrive there as
|
||||
`{"payload": {...}}` and are merged over the defaults. Never make form
|
||||
fields required *with* an incoming edge — that's the envelope's job.
|
||||
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
|
||||
The API watcher syncs + deploys it automatically; the form schema is
|
||||
regenerated per version.
|
||||
|
||||
## Conventions the pipeline relies on
|
||||
|
||||
- `required=True` on a template field: `lfx validate --level 4` demands a
|
||||
value or an edge, so the canonical-flow generator
|
||||
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
|
||||
sentinel value for such fields. The form parser treats whitespace-only
|
||||
values as "no default" — don't ship real whitespace defaults.
|
||||
- Field key = `name` (stable); label = `display_name`; help = `info`.
|
||||
- Unknown future input types degrade to a plain text field in the web
|
||||
form (parser fallback), so adding exotic lfx inputs never breaks runs.
|
||||
|
||||
## Regenerating the bundled fixtures
|
||||
|
||||
After editing the component source (`form_inputs.py`), refresh the
|
||||
canonical + test-fixture exports:
|
||||
|
||||
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
|
||||
docker compose exec -T api uv run pytest tests/test_lfx_validate.py
|
||||
12
backend/components/kirche/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Kirche form-input components (Langflow category ``kirche``).
|
||||
|
||||
GUI-editable by design: plain :class:`lfx.custom.Component` subclasses with
|
||||
``lfx.io`` inputs only (stdlib + ``lfx``), so both the Langflow authoring UI
|
||||
and the LFX runtime can load them. The backend parses the exported node
|
||||
template into a web form (``app/flows/input_schema.py``); the frontend
|
||||
renders that schema with RJSF.
|
||||
"""
|
||||
|
||||
from .form_inputs import CustomInputForm
|
||||
|
||||
__all__ = ["CustomInputForm"]
|
||||
162
backend/components/kirche/form_inputs.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Structured form-input component for kirche-einfach flows.
|
||||
|
||||
A flow that starts with :class:`CustomInputForm` declares the fields of its
|
||||
web form. The API reads the exported node template
|
||||
(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from
|
||||
the ``lfx.io`` input definitions below — one JSON-Schema property per field,
|
||||
keyed by ``name``. Field metadata maps as follows:
|
||||
|
||||
* ``display_name`` → ``title``, ``info`` → ``description``,
|
||||
``value`` → ``default``, ``options`` → ``enum``
|
||||
* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint
|
||||
* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)
|
||||
|
||||
``FileInput`` is a stub for now: it carries a string reference only, real
|
||||
upload comes later.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import ClassVar
|
||||
|
||||
from lfx.custom import Component
|
||||
from lfx.field_typing.range_spec import RangeSpec
|
||||
from lfx.io import (
|
||||
BoolInput,
|
||||
DropdownInput,
|
||||
FileInput,
|
||||
FloatInput,
|
||||
IntInput,
|
||||
MessageTextInput,
|
||||
MultilineInput,
|
||||
MultiselectInput,
|
||||
Output,
|
||||
SecretStrInput,
|
||||
SliderInput,
|
||||
StrInput,
|
||||
)
|
||||
from lfx.schema.message import Message
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if hasattr(value, "text"):
|
||||
return value.text or ""
|
||||
return str(value)
|
||||
|
||||
|
||||
class CustomInputForm(Component):
|
||||
display_name = "CustomInput Form"
|
||||
description = "Collects structured form values into a single JSON message."
|
||||
icon = "ClipboardList"
|
||||
|
||||
inputs: ClassVar[list] = [
|
||||
# Plumbing, not a form field: the API posts submitted web-form
|
||||
# values as the run envelope (same {"payload": {...}} shape the
|
||||
# other kirche components use); they arrive here through a ChatInput
|
||||
# entry node and override the defaults below. Hidden in the GUI and
|
||||
# skipped by the form parser (show=False).
|
||||
MessageTextInput(
|
||||
name="form_values",
|
||||
display_name="Form values (JSON envelope)",
|
||||
info="Submitted values as JSON; merged over the defaults",
|
||||
value="",
|
||||
show=False,
|
||||
),
|
||||
StrInput(
|
||||
name="full_name",
|
||||
display_name="Full name",
|
||||
info="Your first and last name",
|
||||
value="",
|
||||
required=True,
|
||||
),
|
||||
MultilineInput(
|
||||
name="notes",
|
||||
display_name="Notes",
|
||||
info="Anything else we should know",
|
||||
value="",
|
||||
),
|
||||
SecretStrInput(
|
||||
name="password",
|
||||
display_name="Password",
|
||||
info="Optional access phrase",
|
||||
value="",
|
||||
),
|
||||
IntInput(
|
||||
name="age",
|
||||
display_name="Age",
|
||||
info="Your age in years",
|
||||
value=18,
|
||||
),
|
||||
FloatInput(
|
||||
name="amount",
|
||||
display_name="Amount",
|
||||
info="Amount in euros",
|
||||
value=0.0,
|
||||
),
|
||||
BoolInput(
|
||||
name="newsletter",
|
||||
display_name="Newsletter",
|
||||
info="Send me the parish newsletter",
|
||||
value=False,
|
||||
),
|
||||
DropdownInput(
|
||||
name="department",
|
||||
display_name="Department",
|
||||
info="Which team is this for",
|
||||
options=["Music", "Youth", "Seniors", "Admin"],
|
||||
value="Music",
|
||||
),
|
||||
MultiselectInput(
|
||||
name="topics",
|
||||
display_name="Topics",
|
||||
info="What are you interested in",
|
||||
options=["Worship", "Outreach", "Courses"],
|
||||
value=[],
|
||||
),
|
||||
SliderInput(
|
||||
name="satisfaction",
|
||||
display_name="Satisfaction",
|
||||
info="How satisfied are you (1-5)",
|
||||
value=3,
|
||||
range_spec=RangeSpec(min=1, max=5, step=1),
|
||||
),
|
||||
FileInput(
|
||||
name="attachment",
|
||||
display_name="Attachment",
|
||||
info="String reference only for now (no upload yet)",
|
||||
file_types=["pdf", "png", "jpg"],
|
||||
),
|
||||
]
|
||||
outputs: ClassVar[list] = [
|
||||
Output(display_name="Form Data", name="form_data", method="collect"),
|
||||
]
|
||||
|
||||
def collect(self) -> Message:
|
||||
data = {
|
||||
"full_name": _text(self.full_name),
|
||||
"notes": _text(self.notes),
|
||||
"password": _text(self.password),
|
||||
"age": self.age,
|
||||
"amount": self.amount,
|
||||
"newsletter": self.newsletter,
|
||||
"department": _text(self.department),
|
||||
"topics": self.topics,
|
||||
"satisfaction": self.satisfaction,
|
||||
"attachment": _text(self.attachment),
|
||||
}
|
||||
# Submitted values (via form_values) win over the field defaults.
|
||||
# Accepts the raw dict or the run envelope {"payload": {...}}.
|
||||
try:
|
||||
override = json.loads(_text(self.form_values) or "{}")
|
||||
except json.JSONDecodeError:
|
||||
override = {}
|
||||
if isinstance(override, dict):
|
||||
if isinstance(override.get("payload"), dict):
|
||||
override = override["payload"]
|
||||
for key in data:
|
||||
if key in override:
|
||||
data[key] = override[key]
|
||||
return Message(text=json.dumps(data, ensure_ascii=False))
|
||||
|
|
@ -6,10 +6,24 @@ set -e
|
|||
|
||||
cd /srv/kirche-einfach/backend
|
||||
|
||||
.venv/bin/alembic upgrade head
|
||||
# Runtime SQLite file lives in the bind-mount: ensure the dir exists and is
|
||||
# owned by vscode (the entrypoint runs as root, but the editor runs as
|
||||
# vscode — a root-owned data/ dir made the DB unwritable).
|
||||
mkdir -p data
|
||||
if id vscode >/dev/null 2>&1; then
|
||||
chown -R vscode:vscode data 2>/dev/null || true
|
||||
fi
|
||||
|
||||
/opt/venv/bin/alembic upgrade head
|
||||
|
||||
# Migrate ran as root: hand the (possibly just created) DB files back to
|
||||
# vscode so editor-run commands stay writable.
|
||||
if id vscode >/dev/null 2>&1; then
|
||||
chown -R vscode:vscode data 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ "${APP_ENV:-prod}" = "dev" ]; then
|
||||
exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --proxy-headers
|
||||
else
|
||||
exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
exec gosu vscode /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@
|
|||
"list": false,
|
||||
"show": true,
|
||||
"multiline": true,
|
||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
|
||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
|
||||
"fileTypes": [],
|
||||
"file_path": "",
|
||||
"password": false,
|
||||
|
|
@ -399,6 +399,20 @@
|
|||
"allows_loop": false,
|
||||
"group_outputs": false,
|
||||
"tool_mode": true
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"Message"
|
||||
],
|
||||
"selected": "Message",
|
||||
"name": "callback_token",
|
||||
"display_name": "Callback Token",
|
||||
"method": "get_callback_token",
|
||||
"value": "__UNDEFINED__",
|
||||
"cache": true,
|
||||
"allows_loop": false,
|
||||
"group_outputs": false,
|
||||
"tool_mode": true
|
||||
}
|
||||
],
|
||||
"field_order": [
|
||||
|
|
@ -409,7 +423,7 @@
|
|||
"edited": false,
|
||||
"metadata": {
|
||||
"module": "custom_components.envelope",
|
||||
"code_hash": "2b0bd3c3d529",
|
||||
"code_hash": "02e69613e210",
|
||||
"dependencies": {
|
||||
"total_dependencies": 1,
|
||||
"dependencies": [
|
||||
|
|
@ -858,6 +872,32 @@
|
|||
"node": {
|
||||
"template": {
|
||||
"_type": "Component",
|
||||
"callback_token": {
|
||||
"tool_mode": false,
|
||||
"trace_as_input": true,
|
||||
"trace_as_metadata": true,
|
||||
"load_from_db": false,
|
||||
"list": false,
|
||||
"list_add_label": "Add More",
|
||||
"override_skip": false,
|
||||
"required": false,
|
||||
"placeholder": "",
|
||||
"show": true,
|
||||
"name": "callback_token",
|
||||
"value": "",
|
||||
"display_name": "Callback Token",
|
||||
"advanced": false,
|
||||
"api_editable": false,
|
||||
"input_types": [
|
||||
"Message"
|
||||
],
|
||||
"dynamic": false,
|
||||
"info": "",
|
||||
"title_case": false,
|
||||
"track_in_telemetry": false,
|
||||
"type": "str",
|
||||
"_input_type": "MessageTextInput"
|
||||
},
|
||||
"callback_url": {
|
||||
"tool_mode": false,
|
||||
"trace_as_input": true,
|
||||
|
|
@ -891,7 +931,7 @@
|
|||
"list": false,
|
||||
"show": true,
|
||||
"multiline": true,
|
||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
||||
"fileTypes": [],
|
||||
"file_path": "",
|
||||
"password": false,
|
||||
|
|
@ -1040,6 +1080,7 @@
|
|||
"trigger",
|
||||
"run_id",
|
||||
"callback_url",
|
||||
"callback_token",
|
||||
"continuation_flow_id",
|
||||
"facts"
|
||||
],
|
||||
|
|
@ -1048,7 +1089,7 @@
|
|||
"edited": false,
|
||||
"metadata": {
|
||||
"module": "custom_components.approval_callback",
|
||||
"code_hash": "a334376410f7",
|
||||
"code_hash": "28b2f5894566",
|
||||
"dependencies": {
|
||||
"total_dependencies": 2,
|
||||
"dependencies": [
|
||||
|
|
@ -1804,6 +1845,34 @@
|
|||
"target": "CustomComponent-callback",
|
||||
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||
},
|
||||
{
|
||||
"animated": false,
|
||||
"className": "",
|
||||
"data": {
|
||||
"sourceHandle": {
|
||||
"dataType": "CustomComponent",
|
||||
"id": "CustomComponent-envelope",
|
||||
"name": "callback_token",
|
||||
"output_types": [
|
||||
"Message"
|
||||
]
|
||||
},
|
||||
"targetHandle": {
|
||||
"fieldName": "callback_token",
|
||||
"id": "CustomComponent-callback",
|
||||
"inputTypes": [
|
||||
"Message"
|
||||
],
|
||||
"type": "str"
|
||||
}
|
||||
},
|
||||
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
|
||||
"selected": false,
|
||||
"source": "CustomComponent-envelope",
|
||||
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
|
||||
"target": "CustomComponent-callback",
|
||||
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||
},
|
||||
{
|
||||
"animated": false,
|
||||
"className": "",
|
||||
|
|
|
|||
991
backend/flows/intake_form.json
Normal file
|
|
@ -1,9 +1,7 @@
|
|||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
|
|
@ -16,10 +14,11 @@ if config.config_file_name is not None:
|
|||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from app.models import * # noqa: F401,F403 - register all tables
|
||||
from app.settings import settings
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.models import *
|
||||
from app.settings import settings
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
# The URL always comes from settings (repo-root .env or environment),
|
||||
|
|
@ -70,9 +69,7 @@ def run_migrations_online() -> None:
|
|||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
|
|
|||
|
|
@ -1,108 +1,133 @@
|
|||
"""flow engine tables
|
||||
|
||||
Revision ID: 62ea9a5d8f9d
|
||||
Revises:
|
||||
Revises:
|
||||
Create Date: 2026-09-06 22:27:07.129788
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '62ea9a5d8f9d'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
revision: str = "62ea9a5d8f9d"
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('flow_definitions',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['continuation_flow_id'], ['flow_definitions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"flow_definitions",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("description", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["continuation_flow_id"],
|
||||
["flow_definitions.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f('ix_flow_definitions_name'), 'flow_definitions', ['name'], unique=True)
|
||||
op.create_table('flow_versions',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('flow_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('version_no', sa.Integer(), nullable=False),
|
||||
sa.Column('langflow_json', sa.JSON(), nullable=True),
|
||||
sa.Column('lfx_flow_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column('deployed_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('author', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['flow_id'], ['flow_definitions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('flow_id', 'version_no')
|
||||
op.create_index(op.f("ix_flow_definitions_name"), "flow_definitions", ["name"], unique=True)
|
||||
op.create_table(
|
||||
"flow_versions",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("flow_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("version_no", sa.Integer(), nullable=False),
|
||||
sa.Column("langflow_json", sa.JSON(), nullable=True),
|
||||
sa.Column("lfx_flow_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column("deployed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("author", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["flow_id"],
|
||||
["flow_definitions.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("flow_id", "version_no"),
|
||||
)
|
||||
op.create_index(op.f('ix_flow_versions_flow_id'), 'flow_versions', ['flow_id'], unique=False)
|
||||
op.create_index(op.f('ix_flow_versions_version_no'), 'flow_versions', ['version_no'], unique=False)
|
||||
op.create_table('flow_runs',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('version_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False),
|
||||
sa.Column('input', sa.JSON(), nullable=True),
|
||||
sa.Column('output', sa.JSON(), nullable=True),
|
||||
sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_index(op.f("ix_flow_versions_flow_id"), "flow_versions", ["flow_id"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_flow_versions_version_no"), "flow_versions", ["version_no"], unique=False
|
||||
)
|
||||
op.create_index(op.f('ix_flow_runs_status'), 'flow_runs', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_flow_runs_tenant_id'), 'flow_runs', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_flow_runs_version_id'), 'flow_runs', ['version_id'], unique=False)
|
||||
op.create_table('approval_tasks',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('run_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('version_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
|
||||
sa.Column('payload', sa.JSON(), nullable=True),
|
||||
sa.Column('continuation_flow_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('approver', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column('decision_note', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['run_id'], ['flow_runs.id'], ),
|
||||
sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
"flow_runs",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("version_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False),
|
||||
sa.Column("input", sa.JSON(), nullable=True),
|
||||
sa.Column("output", sa.JSON(), nullable=True),
|
||||
sa.Column("session_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["version_id"],
|
||||
["flow_versions.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_flow_runs_status"), "flow_runs", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_flow_runs_tenant_id"), "flow_runs", ["tenant_id"], unique=False)
|
||||
op.create_index(op.f("ix_flow_runs_version_id"), "flow_runs", ["version_id"], unique=False)
|
||||
op.create_table(
|
||||
"approval_tasks",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("run_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("version_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("title", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=True),
|
||||
sa.Column("continuation_flow_id", sa.Uuid(), nullable=True),
|
||||
sa.Column("approver", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column("decision_note", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["run_id"],
|
||||
["flow_runs.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["version_id"],
|
||||
["flow_versions.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_approval_tasks_run_id"), "approval_tasks", ["run_id"], unique=False)
|
||||
op.create_index(op.f("ix_approval_tasks_status"), "approval_tasks", ["status"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_approval_tasks_tenant_id"), "approval_tasks", ["tenant_id"], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_approval_tasks_version_id"), "approval_tasks", ["version_id"], unique=False
|
||||
)
|
||||
op.create_index(op.f('ix_approval_tasks_run_id'), 'approval_tasks', ['run_id'], unique=False)
|
||||
op.create_index(op.f('ix_approval_tasks_status'), 'approval_tasks', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_approval_tasks_tenant_id'), 'approval_tasks', ['tenant_id'], unique=False)
|
||||
op.create_index(op.f('ix_approval_tasks_version_id'), 'approval_tasks', ['version_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_approval_tasks_version_id'), table_name='approval_tasks')
|
||||
op.drop_index(op.f('ix_approval_tasks_tenant_id'), table_name='approval_tasks')
|
||||
op.drop_index(op.f('ix_approval_tasks_status'), table_name='approval_tasks')
|
||||
op.drop_index(op.f('ix_approval_tasks_run_id'), table_name='approval_tasks')
|
||||
op.drop_table('approval_tasks')
|
||||
op.drop_index(op.f('ix_flow_runs_version_id'), table_name='flow_runs')
|
||||
op.drop_index(op.f('ix_flow_runs_tenant_id'), table_name='flow_runs')
|
||||
op.drop_index(op.f('ix_flow_runs_status'), table_name='flow_runs')
|
||||
op.drop_table('flow_runs')
|
||||
op.drop_index(op.f('ix_flow_versions_version_no'), table_name='flow_versions')
|
||||
op.drop_index(op.f('ix_flow_versions_flow_id'), table_name='flow_versions')
|
||||
op.drop_table('flow_versions')
|
||||
op.drop_index(op.f('ix_flow_definitions_name'), table_name='flow_definitions')
|
||||
op.drop_table('flow_definitions')
|
||||
op.drop_index(op.f("ix_approval_tasks_version_id"), table_name="approval_tasks")
|
||||
op.drop_index(op.f("ix_approval_tasks_tenant_id"), table_name="approval_tasks")
|
||||
op.drop_index(op.f("ix_approval_tasks_status"), table_name="approval_tasks")
|
||||
op.drop_index(op.f("ix_approval_tasks_run_id"), table_name="approval_tasks")
|
||||
op.drop_table("approval_tasks")
|
||||
op.drop_index(op.f("ix_flow_runs_version_id"), table_name="flow_runs")
|
||||
op.drop_index(op.f("ix_flow_runs_tenant_id"), table_name="flow_runs")
|
||||
op.drop_index(op.f("ix_flow_runs_status"), table_name="flow_runs")
|
||||
op.drop_table("flow_runs")
|
||||
op.drop_index(op.f("ix_flow_versions_version_no"), table_name="flow_versions")
|
||||
op.drop_index(op.f("ix_flow_versions_flow_id"), table_name="flow_versions")
|
||||
op.drop_table("flow_versions")
|
||||
op.drop_index(op.f("ix_flow_definitions_name"), table_name="flow_definitions")
|
||||
op.drop_table("flow_definitions")
|
||||
# ### end Alembic commands ###
|
||||
|
|
|
|||
96
backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""auth tables (users, access requests, magic links, sessions)
|
||||
|
||||
Revision ID: a3f1c9e2b4d7
|
||||
Revises: d932b6993f9a
|
||||
Create Date: 2026-09-11 15:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a3f1c9e2b4d7"
|
||||
down_revision: str | Sequence[str] | None = "d932b6993f9a"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("role", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column("tenant_id", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||
op.create_table(
|
||||
"access_requests",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False),
|
||||
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=False),
|
||||
sa.Column("reason", sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False),
|
||||
sa.Column("status", sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("decided_by", sqlmodel.sql.sqltypes.AutoString(length=320), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_access_requests_email"), "access_requests", ["email"], unique=False)
|
||||
op.create_index(op.f("ix_access_requests_status"), "access_requests", ["status"], unique=False)
|
||||
op.create_table(
|
||||
"magic_link_tokens",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("token_hash", sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_magic_link_tokens_token_hash"), "magic_link_tokens", ["token_hash"], unique=True
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_magic_link_tokens_user_id"), "magic_link_tokens", ["user_id"], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
"user_sessions",
|
||||
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
|
||||
sa.Column("user_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_user_sessions_user_id"), "user_sessions", ["user_id"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(op.f("ix_user_sessions_user_id"), table_name="user_sessions")
|
||||
op.drop_table("user_sessions")
|
||||
op.drop_index(op.f("ix_magic_link_tokens_user_id"), table_name="magic_link_tokens")
|
||||
op.drop_index(op.f("ix_magic_link_tokens_token_hash"), table_name="magic_link_tokens")
|
||||
op.drop_table("magic_link_tokens")
|
||||
op.drop_index(op.f("ix_access_requests_status"), table_name="access_requests")
|
||||
op.drop_index(op.f("ix_access_requests_email"), table_name="access_requests")
|
||||
op.drop_table("access_requests")
|
||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||
op.drop_table("users")
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"""cache form schema per flow version
|
||||
|
||||
Revision ID: b7e2d4a1c9f3
|
||||
Revises: a3f1c9e2b4d7
|
||||
Create Date: 2026-09-11 20:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b7e2d4a1c9f3"
|
||||
down_revision: str | Sequence[str] | None = "a3f1c9e2b4d7"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column(
|
||||
"flow_versions",
|
||||
sa.Column("input_schema", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column("flow_versions", "input_schema")
|
||||
|
|
@ -5,18 +5,18 @@ Revises: 62ea9a5d8f9d
|
|||
Create Date: 2026-09-07 08:40:48.986026
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'd932b6993f9a'
|
||||
down_revision: Union[str, Sequence[str], None] = '62ea9a5d8f9d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
revision: str = "d932b6993f9a"
|
||||
down_revision: str | Sequence[str] | None = "62ea9a5d8f9d"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
|
|
|||
|
|
@ -27,9 +27,22 @@ testpaths = ["tests"]
|
|||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"lfx",
|
||||
# Pinned to the Langflow minor (contract: major.minor must match).
|
||||
# Bump together with LANGFLOW_VERSION (.env.example) + Dockerfile.lfx.
|
||||
"lfx~=1.12.0",
|
||||
"pytest>=8",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cov>=6",
|
||||
"respx>=0.22",
|
||||
"ruff>=0.16,<0.17",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
# Default rule set minus EXE: Docker Desktop bind-mounts report every file as
|
||||
# executable (git index stays 100644), so EXE002 would fire on the dev mount
|
||||
# but never on Linux CI. Everything else is enforced as ruff's default.
|
||||
ignore = ["EXE002"]
|
||||
|
|
|
|||
111
backend/scripts/check_prod.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Validate a prod `.env` file before (re)deploying.
|
||||
|
||||
Usage (from backend/ or repo root):
|
||||
uv run python scripts/check_prod.py --env ../.env
|
||||
uv run python scripts/check_prod.py # defaults to repo-root .env
|
||||
|
||||
Fails (exit 1) on missing/weak secrets and insecure modes; warns on
|
||||
soft misconfigurations. Safe to run anywhere: reads one file, no imports
|
||||
from app (stdlib only), prints no secret values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parent.parent
|
||||
REPO_ROOT = BACKEND.parent
|
||||
|
||||
MIN_TOKEN = 32
|
||||
MIN_PASSWORD = 16
|
||||
MIN_API_KEY = 16
|
||||
|
||||
|
||||
def parse_env(path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip("'\"")
|
||||
if key.startswith("export "):
|
||||
key = key[len("export ") :].strip()
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
|
||||
"""(failures, warnings) for a parsed env file."""
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
def secret(name: str, minimum: int, what: str) -> None:
|
||||
value = values.get(name, "")
|
||||
if not value:
|
||||
failures.append(f"{name} is empty — generate one: openssl rand -hex 32")
|
||||
elif len(value) < minimum:
|
||||
failures.append(f"{name} too short ({len(value)} < {minimum} chars)")
|
||||
|
||||
def placeholder(name: str, prefixes: tuple[str, ...]) -> None:
|
||||
value = values.get(name, "")
|
||||
if value and any(value == p or value.startswith(p) for p in prefixes):
|
||||
failures.append(f"{name} still holds an example placeholder")
|
||||
|
||||
# Shared + service API keys.
|
||||
secret("CALLBACK_TOKEN", MIN_TOKEN, "callback token")
|
||||
secret("LANGFLOW_API_KEY", MIN_API_KEY, "langflow api key")
|
||||
placeholder("LANGFLOW_API_KEY", ("sk-",))
|
||||
secret("LFX_API_KEY", MIN_API_KEY, "lfx api key")
|
||||
placeholder("LFX_API_KEY", ("lfx-",))
|
||||
# Langflow's own auth (internet-facing authoring UI).
|
||||
secret("LANGFLOW_SUPERUSER_PASSWORD", MIN_PASSWORD, "langflow superuser password")
|
||||
secret("LANGFLOW_SECRET_KEY", MIN_TOKEN, "langflow secret key")
|
||||
|
||||
# Insecure modes.
|
||||
if values.get("FRONTEND_MODE", "preview") == "dev":
|
||||
failures.append("FRONTEND_MODE=dev serves the Vite dev server — use preview in prod")
|
||||
if values.get("APP_ENV", "prod") == "dev":
|
||||
failures.append("APP_ENV=dev enables autoreload — unset it in prod")
|
||||
if values.get("LANGFLOW_AUTO_LOGIN", "false").lower() == "true":
|
||||
failures.append("LANGFLOW_AUTO_LOGIN=true allows passwordless superuser access")
|
||||
|
||||
# Soft misconfigurations.
|
||||
if not values.get("ADMIN_EMAILS", ""):
|
||||
warnings.append("ADMIN_EMAILS empty — nobody can bootstrap an admin account")
|
||||
if values.get("SMTP_HOST", "") and not values.get("SMTP_PASSWORD", ""):
|
||||
warnings.append("SMTP_HOST without SMTP_PASSWORD — magic-link mail will fail")
|
||||
if not values.get("CONTACT_TO", ""):
|
||||
warnings.append("CONTACT_TO empty — access-request notifications go nowhere")
|
||||
frontend_url = values.get("FRONTEND_URL", "")
|
||||
if frontend_url and not frontend_url.startswith("https://"):
|
||||
warnings.append("FRONTEND_URL is not https — cookies and tokens travel in cleartext")
|
||||
|
||||
return failures, warnings
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--env", default=str(REPO_ROOT / ".env"))
|
||||
args = parser.parse_args()
|
||||
path = Path(args.env)
|
||||
if not path.is_file():
|
||||
print(f"no env file at {path}")
|
||||
return 1
|
||||
failures, warnings = check(parse_env(path))
|
||||
for message in warnings:
|
||||
print(f"WARN: {message}")
|
||||
for message in failures:
|
||||
print(f"FAIL: {message}")
|
||||
if failures:
|
||||
print(f"{len(failures)} blocking issue(s) — fix before deploying")
|
||||
return 1
|
||||
print("prod env OK" + (f" ({len(warnings)} warning(s))" if warnings else ""))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -87,6 +87,77 @@ def custom_node(
|
|||
return node_dict
|
||||
|
||||
|
||||
def kirche_node(
|
||||
node_id: str,
|
||||
values: dict | None = None,
|
||||
position: tuple[int, int] = (0, 0),
|
||||
) -> dict:
|
||||
"""A node from our GUI-editable category lib (backend/components/kirche).
|
||||
|
||||
Built with lfx's own frontend-node serialization, exactly like
|
||||
``Component.to_frontend_node()`` — minus its final ``Template.from_dict``
|
||||
round-trip: lfx 1.12.x cannot re-instantiate ``MultiselectInput`` (it
|
||||
emits ``list``, the constructor forbids extras; their own builtins fail
|
||||
the same way). The template dict before that round-trip is complete and
|
||||
faithful, so we use it directly and append the ``code`` field by hand
|
||||
(mirroring the shape ``build_custom_component_template`` produces).
|
||||
"""
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||
from kirche.form_inputs import CustomInputForm
|
||||
from lfx.template.frontend_node.custom_components import (
|
||||
ComponentFrontendNode,
|
||||
)
|
||||
|
||||
comp_name = getattr(CustomInputForm, "name", None) or "CustomInputForm"
|
||||
inst = CustomInputForm()
|
||||
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
|
||||
inst._map_parameters_on_frontend_node(frontend_node)
|
||||
node = frontend_node.to_dict(keep_name=False)
|
||||
node = inst._update_template(node)
|
||||
inst._map_parameters_on_template(node["template"])
|
||||
if not getattr(inst, "_code", None):
|
||||
inst.set_class_code()
|
||||
node["template"]["code"] = {
|
||||
"advanced": True,
|
||||
"api_editable": False,
|
||||
"dynamic": True,
|
||||
"fileTypes": [],
|
||||
"info": "",
|
||||
"list": False,
|
||||
"load_from_db": False,
|
||||
"multiline": True,
|
||||
"name": "code",
|
||||
"password": False,
|
||||
"placeholder": "",
|
||||
"required": True,
|
||||
"show": True,
|
||||
"title_case": False,
|
||||
"type": "code",
|
||||
"value": inst._code,
|
||||
}
|
||||
node_dict = {
|
||||
"data": {"node": node, "type": comp_name, "id": node_id},
|
||||
"id": node_id,
|
||||
}
|
||||
node_dict = _normalize(node_dict, node_id, comp_name, *position)
|
||||
for field, value in (values or {}).items():
|
||||
node_dict["data"]["node"]["template"][field]["value"] = value
|
||||
# lfx validate --level 4 errors on required inputs with no value and no
|
||||
# incoming edge. Our form fields are filled at runtime via the
|
||||
# form_values envelope (collect() merges), never via edges — so required
|
||||
# template fields carry a single-space sentinel value. The form parser
|
||||
# (app/flows/input_schema.py) treats whitespace-only values as "no
|
||||
# default", keeping the web form clean.
|
||||
for field_def in node_dict["data"]["node"]["template"].values():
|
||||
if (
|
||||
isinstance(field_def, dict)
|
||||
and field_def.get("required")
|
||||
and field_def.get("value") in (None, "", [], {})
|
||||
):
|
||||
field_def["value"] = " "
|
||||
return node_dict
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# edges
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -135,9 +206,7 @@ def edge(src: dict, src_output: str, dst: dict, dst_input: str) -> dict:
|
|||
# --------------------------------------------------------------------------
|
||||
# flow document
|
||||
# --------------------------------------------------------------------------
|
||||
def flow_doc(
|
||||
name: str, description: str, nodes: list[dict], edges: list[dict]
|
||||
) -> dict:
|
||||
def flow_doc(name: str, description: str, nodes: list[dict], edges: list[dict]) -> dict:
|
||||
return {
|
||||
"data": {
|
||||
"nodes": nodes,
|
||||
|
|
@ -161,7 +230,7 @@ def flow_doc(
|
|||
# --------------------------------------------------------------------------
|
||||
# custom component code
|
||||
# --------------------------------------------------------------------------
|
||||
RULE_CHECK_CODE = '''from lfx.custom import Component
|
||||
RULE_CHECK_CODE = """from lfx.custom import Component
|
||||
from lfx.io import MessageTextInput, Output
|
||||
from lfx.schema.message import Message
|
||||
import json
|
||||
|
|
@ -205,9 +274,9 @@ class RuleCheckComponent(Component):
|
|||
except (TypeError, ValueError):
|
||||
schwelle = 200000.0
|
||||
return Message(text="PRUEFUNG" if betrag > schwelle else "OK")
|
||||
'''
|
||||
"""
|
||||
|
||||
ENVELOPE_CODE = '''from lfx.custom import Component
|
||||
ENVELOPE_CODE = """from lfx.custom import Component
|
||||
from lfx.io import MessageTextInput, Output
|
||||
from lfx.schema.message import Message
|
||||
import json
|
||||
|
|
@ -233,6 +302,11 @@ class EnvelopeComponent(Component):
|
|||
name="continuation",
|
||||
method="get_continuation",
|
||||
),
|
||||
Output(
|
||||
display_name="Callback Token",
|
||||
name="callback_token",
|
||||
method="get_callback_token",
|
||||
),
|
||||
]
|
||||
|
||||
def _text(self, value) -> str:
|
||||
|
|
@ -264,9 +338,12 @@ class EnvelopeComponent(Component):
|
|||
def get_continuation(self) -> Message:
|
||||
value = self._env().get("continuation_flow_id")
|
||||
return Message(text=str(value) if value else "")
|
||||
'''
|
||||
|
||||
APPROVAL_CALLBACK_CODE = '''from lfx.custom import Component
|
||||
def get_callback_token(self) -> Message:
|
||||
return Message(text=str(self._env().get("callback_token", "")))
|
||||
"""
|
||||
|
||||
APPROVAL_CALLBACK_CODE = """from lfx.custom import Component
|
||||
from lfx.io import MessageTextInput, Output
|
||||
from lfx.schema.message import Message
|
||||
|
||||
|
|
@ -279,6 +356,10 @@ class ApprovalCallbackComponent(Component):
|
|||
MessageTextInput(name="trigger", display_name="Trigger"),
|
||||
MessageTextInput(name="run_id", display_name="Run ID"),
|
||||
MessageTextInput(name="callback_url", display_name="Callback URL"),
|
||||
MessageTextInput(
|
||||
name="callback_token",
|
||||
display_name="Callback Token",
|
||||
),
|
||||
MessageTextInput(
|
||||
name="continuation_flow_id",
|
||||
display_name="Continuation Flow ID",
|
||||
|
|
@ -315,13 +396,14 @@ class ApprovalCallbackComponent(Component):
|
|||
"payload": {"verdict": trigger, "facts": facts},
|
||||
"continuation_flow_id": self._text(self.continuation_flow_id) or None,
|
||||
}
|
||||
headers = {"X-Callback-Token": self._text(self.callback_token)}
|
||||
try:
|
||||
response = httpx.post(url, json=body, timeout=10.0)
|
||||
response = httpx.post(url, json=body, headers=headers, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
except Exception as exc:
|
||||
return Message(text=f"CALLBACK_FAILED: {exc}")
|
||||
return Message(text="PENDING_HUMAN_APPROVAL")
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
|
@ -384,9 +466,7 @@ def build_approval_handoff() -> dict:
|
|||
"ChatInput-entry",
|
||||
position=(0, 300),
|
||||
)
|
||||
envelope = custom_node(
|
||||
ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300)
|
||||
)
|
||||
envelope = custom_node(ENVELOPE_CODE, "CustomComponent-envelope", position=(400, 300))
|
||||
rule = custom_node(RULE_CHECK_CODE, "CustomComponent-rule", position=(800, 300))
|
||||
router = builtin_node(
|
||||
"lfx.components.flow_controls.conditional_router",
|
||||
|
|
@ -420,6 +500,7 @@ def build_approval_handoff() -> dict:
|
|||
edge(router, "true_result", callback, "trigger"),
|
||||
edge(envelope, "run_id", callback, "run_id"),
|
||||
edge(envelope, "callback_url", callback, "callback_url"),
|
||||
edge(envelope, "callback_token", callback, "callback_token"),
|
||||
edge(envelope, "continuation", callback, "continuation_flow_id"),
|
||||
edge(envelope, "payload", callback, "facts"),
|
||||
edge(callback, "result", out_pending, "input_value"),
|
||||
|
|
@ -433,12 +514,43 @@ def build_approval_handoff() -> dict:
|
|||
)
|
||||
|
||||
|
||||
def build_intake_form() -> dict:
|
||||
entry = builtin_node(
|
||||
"lfx.components.input_output.chat",
|
||||
"ChatInput",
|
||||
"ChatInput-entry",
|
||||
position=(0, 200),
|
||||
)
|
||||
form = kirche_node(
|
||||
"CustomInputForm-intake",
|
||||
position=(400, 200),
|
||||
)
|
||||
out = builtin_node(
|
||||
"lfx.components.input_output.chat_output",
|
||||
"ChatOutput",
|
||||
"ChatOutput-collected",
|
||||
position=(800, 200),
|
||||
)
|
||||
nodes = [entry, form, out]
|
||||
edges = [
|
||||
edge(entry, "message", form, "form_values"),
|
||||
edge(form, "form_data", out, "input_value"),
|
||||
]
|
||||
return flow_doc(
|
||||
"intake_form",
|
||||
"Structured intake via CustomInputForm (parsed into a web form).",
|
||||
nodes,
|
||||
edges,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
FLOW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
flows = {
|
||||
"simple_rule.json": build_simple_rule(),
|
||||
"approval_handoff.json": build_approval_handoff(),
|
||||
"intake_form.json": build_intake_form(),
|
||||
}
|
||||
for filename, doc in flows.items():
|
||||
payload = json.dumps(doc, indent=2, ensure_ascii=False) + "\n"
|
||||
|
|
|
|||
|
|
@ -10,18 +10,31 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND = Path(__file__).resolve().parent.parent
|
||||
VENV_BIN = BACKEND / ".venv" / "bin"
|
||||
# Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH.
|
||||
VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin")
|
||||
FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
|
||||
|
||||
LFX_PORT = 6004
|
||||
API_PORT = 8000
|
||||
|
||||
def _free_port() -> int:
|
||||
# Ephemeral ports: the dev API/LFX may already occupy the defaults
|
||||
# (e.g. when smoke runs inside the api container itself).
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
LFX_PORT = _free_port()
|
||||
API_PORT = _free_port()
|
||||
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
|
||||
|
||||
|
||||
|
|
@ -41,6 +54,9 @@ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | s
|
|||
import httpx
|
||||
|
||||
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c:
|
||||
# Management + runs are login-gated; the smoke admin session is
|
||||
# planted directly in the throwaway DB (see main()).
|
||||
c.cookies.set("kirche_session", "smoke-session", domain="127.0.0.1")
|
||||
r = c.request(method, path, json=body)
|
||||
try:
|
||||
return r.status_code, r.json()
|
||||
|
|
@ -58,9 +74,7 @@ def main() -> None:
|
|||
tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-"))
|
||||
flow_dir = Path("/tmp/smoke-e2e-flows")
|
||||
flow_dir.mkdir(exist_ok=True)
|
||||
(flow_dir / "seed.json").write_text(
|
||||
(FIXTURES / "simple_rule.json").read_text()
|
||||
)
|
||||
(flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text())
|
||||
# Migrate a throwaway SQLite schema to head before starting the API.
|
||||
from alembic import command as alembic_command
|
||||
from alembic.config import Config
|
||||
|
|
@ -69,8 +83,36 @@ def main() -> None:
|
|||
cfg = Config()
|
||||
cfg.set_main_option("script_location", str(BACKEND / "migrations"))
|
||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
# migrations/env.py takes the URL from settings (not the ini value):
|
||||
# point this process at the throwaway DB before migrating.
|
||||
from app.settings import settings as app_settings
|
||||
|
||||
app_settings.database_url = db_url
|
||||
alembic_command.upgrade(cfg, "head")
|
||||
|
||||
# Plant an admin session: management + runs require login, and there is
|
||||
# no SMTP in smoke to deliver a magic link.
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlmodel import Session, create_engine
|
||||
|
||||
from app.models import User, UserSession
|
||||
|
||||
engine = create_engine(db_url)
|
||||
with Session(engine) as s:
|
||||
user = User(name="smoke", email="smoke@example.com", role="admin")
|
||||
s.add(user)
|
||||
s.flush()
|
||||
s.add(
|
||||
UserSession(
|
||||
id="smoke-session",
|
||||
user_id=user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
s.commit()
|
||||
engine.dispose()
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
|
||||
|
|
@ -81,16 +123,29 @@ def main() -> None:
|
|||
"FLOWS_WATCH_ENABLED": "false",
|
||||
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
|
||||
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
|
||||
"CALLBACK_TOKEN": "smoke-callback-token",
|
||||
}
|
||||
lfx = subprocess.Popen(
|
||||
[str(VENV_BIN / "lfx"), "serve", "--host", "127.0.0.1",
|
||||
"--port", str(LFX_PORT), "--flow-dir", str(flow_dir), str(flow_dir)],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env,
|
||||
[
|
||||
str(VENV_BIN / "lfx"),
|
||||
"serve",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(LFX_PORT),
|
||||
"--flow-dir",
|
||||
str(flow_dir),
|
||||
str(flow_dir),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
uvicorn = subprocess.Popen(
|
||||
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1",
|
||||
"--port", str(API_PORT)],
|
||||
cwd=str(BACKEND), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)],
|
||||
cwd=str(BACKEND),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
|
|
@ -106,7 +161,8 @@ def main() -> None:
|
|||
|
||||
fx = json.loads((FIXTURES / "simple_rule.json").read_text())
|
||||
s, ver = api(
|
||||
"POST", f"/api/flows/{fid}/versions",
|
||||
"POST",
|
||||
f"/api/flows/{fid}/versions",
|
||||
{"langflow_json": fx, "author": "smoke"},
|
||||
)
|
||||
check("create version", s == 201, f"v{ver.get('version_no')}")
|
||||
|
|
@ -115,17 +171,25 @@ def main() -> None:
|
|||
check("deploy", s == 200 and dep.get("lfx_flow_id"), json.dumps(dep)[:160])
|
||||
|
||||
s, run = api(
|
||||
"POST", f"/api/flows/{fid}/run",
|
||||
"POST",
|
||||
f"/api/flows/{fid}/run",
|
||||
{"input": {"betrag": 120000, "schwelle": 200000}},
|
||||
)
|
||||
check("run standard", s == 200 and run.get("status") == "completed",
|
||||
json.dumps(run.get("output"))[:120])
|
||||
check(
|
||||
"run standard",
|
||||
s == 200 and run.get("status") == "completed",
|
||||
json.dumps(run.get("output"))[:120],
|
||||
)
|
||||
|
||||
s, run2 = api(
|
||||
"POST", f"/api/flows/{fid}/run",
|
||||
"POST",
|
||||
f"/api/flows/{fid}/run",
|
||||
{"input": {"betrag": 999000, "schwelle": 200000}},
|
||||
)
|
||||
check("run sonderfall", s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""))
|
||||
check(
|
||||
"run sonderfall",
|
||||
s == 200 and "Sonderfall" in (run2.get("output") or {}).get("result", ""),
|
||||
)
|
||||
|
||||
s, tasks = api("GET", "/api/approvals")
|
||||
check("approvals empty (no callback flow)", s == 200 and tasks == [])
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from sqlmodel import Session
|
|||
|
||||
from app.db import get_engine, reset_engine
|
||||
from app.flows import export, sync
|
||||
from app.flows.langflow_client import LangflowClient
|
||||
from app.flows.langflow_client import LangflowClient, LangflowError
|
||||
from app.flows.lfx_client import LfxClient
|
||||
from app.flows.schemas import SyncReport
|
||||
from app.settings import settings
|
||||
|
|
@ -38,7 +38,7 @@ async def pull_from_langflow(flows_dir: Path) -> dict:
|
|||
name = doc.get("name") or flow_id
|
||||
path = export.write_flow_file(flows_dir, doc, name=name)
|
||||
pulled.append(path.stem)
|
||||
except Exception as exc: # noqa: BLE001 - pull-all is total
|
||||
except (LangflowError, OSError, ValueError) as exc:
|
||||
errors[flow_id] = str(exc)
|
||||
return {"pulled": sorted(pulled), "errors": errors}
|
||||
|
||||
|
|
@ -73,12 +73,11 @@ async def main_async(args) -> int:
|
|||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--pull", action="store_true",
|
||||
help="export all Langflow flows to backend/flows first")
|
||||
parser.add_argument("--sync", action="store_true",
|
||||
help="reconcile backend/flows into DB + LFX")
|
||||
parser.add_argument("--dir", default=None,
|
||||
help="flows directory (default: settings.flows_dir)")
|
||||
parser.add_argument(
|
||||
"--pull", action="store_true", help="export all Langflow flows to backend/flows first"
|
||||
)
|
||||
parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX")
|
||||
parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)")
|
||||
args = parser.parse_args()
|
||||
if not args.pull and not args.sync:
|
||||
args.sync = True
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-"))
|
|||
# Must precede all `app.*` imports: Settings is instantiated at import time.
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
|
||||
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
|
||||
# Flow callbacks (POST /api/approvals) authenticate with this token.
|
||||
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
|
||||
# The embedded watcher + startup sync stay off in tests; sync is exercised
|
||||
# directly (sync_from_directory) with per-test tmp dirs instead.
|
||||
os.environ["FLOWS_WATCH_ENABLED"] = "false"
|
||||
|
|
@ -81,6 +83,48 @@ async def client():
|
|||
yield c
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def login(client, session):
|
||||
"""Log the test client in as a fresh user: ``await login()`` (admin)
|
||||
or ``await login(role="member", tenant_id="pfarramt-a")``."""
|
||||
|
||||
async def _login(
|
||||
email: str = "admin@example.com",
|
||||
role: str = "admin",
|
||||
tenant_id: str = "default",
|
||||
):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from app.auth.deps import SESSION_COOKIE
|
||||
from app.models import User, UserSession
|
||||
|
||||
# Idempotent: re-login updates the existing row (tests switch
|
||||
# identity mid-test by calling login() again).
|
||||
user = session.exec(select(User).where(User.email == email)).first()
|
||||
if user is None:
|
||||
user = User(name="Test", email=email, role=role, tenant_id=tenant_id)
|
||||
session.add(user)
|
||||
else:
|
||||
user.role = role
|
||||
user.tenant_id = tenant_id
|
||||
session.add(user)
|
||||
session.flush()
|
||||
user_session = UserSession(
|
||||
id=f"test-session-{uuid4().hex}",
|
||||
user_id=user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(days=1),
|
||||
)
|
||||
session.add(user_session)
|
||||
session.commit()
|
||||
client.cookies.set(SESSION_COOKIE, user_session.id)
|
||||
return user
|
||||
|
||||
return _login
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def lfx_server(tmp_path_factory):
|
||||
"""Real `lfx serve` subprocess on a free port; points settings at it.
|
||||
|
|
@ -107,9 +151,7 @@ def lfx_server(tmp_path_factory):
|
|||
sock.close()
|
||||
|
||||
flow_dir = tmp_path_factory.mktemp("lfx-flows")
|
||||
seed = (
|
||||
Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json"
|
||||
).read_text()
|
||||
seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text()
|
||||
(flow_dir / "seed.json").write_text(seed)
|
||||
|
||||
env = {
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@
|
|||
"list": false,
|
||||
"show": true,
|
||||
"multiline": true,
|
||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
|
||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
|
||||
"fileTypes": [],
|
||||
"file_path": "",
|
||||
"password": false,
|
||||
|
|
@ -399,6 +399,20 @@
|
|||
"allows_loop": false,
|
||||
"group_outputs": false,
|
||||
"tool_mode": true
|
||||
},
|
||||
{
|
||||
"types": [
|
||||
"Message"
|
||||
],
|
||||
"selected": "Message",
|
||||
"name": "callback_token",
|
||||
"display_name": "Callback Token",
|
||||
"method": "get_callback_token",
|
||||
"value": "__UNDEFINED__",
|
||||
"cache": true,
|
||||
"allows_loop": false,
|
||||
"group_outputs": false,
|
||||
"tool_mode": true
|
||||
}
|
||||
],
|
||||
"field_order": [
|
||||
|
|
@ -409,7 +423,7 @@
|
|||
"edited": false,
|
||||
"metadata": {
|
||||
"module": "custom_components.envelope",
|
||||
"code_hash": "2b0bd3c3d529",
|
||||
"code_hash": "02e69613e210",
|
||||
"dependencies": {
|
||||
"total_dependencies": 1,
|
||||
"dependencies": [
|
||||
|
|
@ -858,6 +872,32 @@
|
|||
"node": {
|
||||
"template": {
|
||||
"_type": "Component",
|
||||
"callback_token": {
|
||||
"tool_mode": false,
|
||||
"trace_as_input": true,
|
||||
"trace_as_metadata": true,
|
||||
"load_from_db": false,
|
||||
"list": false,
|
||||
"list_add_label": "Add More",
|
||||
"override_skip": false,
|
||||
"required": false,
|
||||
"placeholder": "",
|
||||
"show": true,
|
||||
"name": "callback_token",
|
||||
"value": "",
|
||||
"display_name": "Callback Token",
|
||||
"advanced": false,
|
||||
"api_editable": false,
|
||||
"input_types": [
|
||||
"Message"
|
||||
],
|
||||
"dynamic": false,
|
||||
"info": "",
|
||||
"title_case": false,
|
||||
"track_in_telemetry": false,
|
||||
"type": "str",
|
||||
"_input_type": "MessageTextInput"
|
||||
},
|
||||
"callback_url": {
|
||||
"tool_mode": false,
|
||||
"trace_as_input": true,
|
||||
|
|
@ -891,7 +931,7 @@
|
|||
"list": false,
|
||||
"show": true,
|
||||
"multiline": true,
|
||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
||||
"fileTypes": [],
|
||||
"file_path": "",
|
||||
"password": false,
|
||||
|
|
@ -1040,6 +1080,7 @@
|
|||
"trigger",
|
||||
"run_id",
|
||||
"callback_url",
|
||||
"callback_token",
|
||||
"continuation_flow_id",
|
||||
"facts"
|
||||
],
|
||||
|
|
@ -1048,7 +1089,7 @@
|
|||
"edited": false,
|
||||
"metadata": {
|
||||
"module": "custom_components.approval_callback",
|
||||
"code_hash": "a334376410f7",
|
||||
"code_hash": "28b2f5894566",
|
||||
"dependencies": {
|
||||
"total_dependencies": 2,
|
||||
"dependencies": [
|
||||
|
|
@ -1804,6 +1845,34 @@
|
|||
"target": "CustomComponent-callback",
|
||||
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||
},
|
||||
{
|
||||
"animated": false,
|
||||
"className": "",
|
||||
"data": {
|
||||
"sourceHandle": {
|
||||
"dataType": "CustomComponent",
|
||||
"id": "CustomComponent-envelope",
|
||||
"name": "callback_token",
|
||||
"output_types": [
|
||||
"Message"
|
||||
]
|
||||
},
|
||||
"targetHandle": {
|
||||
"fieldName": "callback_token",
|
||||
"id": "CustomComponent-callback",
|
||||
"inputTypes": [
|
||||
"Message"
|
||||
],
|
||||
"type": "str"
|
||||
}
|
||||
},
|
||||
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
|
||||
"selected": false,
|
||||
"source": "CustomComponent-envelope",
|
||||
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
|
||||
"target": "CustomComponent-callback",
|
||||
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||
},
|
||||
{
|
||||
"animated": false,
|
||||
"className": "",
|
||||
|
|
|
|||
991
backend/tests/fixtures/flows/intake_form.json
vendored
Normal file
|
|
@ -30,51 +30,70 @@ async def _version_and_deploy(client, flow_id, fixture="simple_rule.json"):
|
|||
return response.json()
|
||||
|
||||
|
||||
async def _handoff_task(client, live_server, continuation_flow_id=None):
|
||||
async def _handoff_task(client, live_server, monkeypatch):
|
||||
"""Run approval_handoff over threshold; returns (run, task)."""
|
||||
from app.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
|
||||
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
||||
body = {
|
||||
"input": {"betrag": 250000, "schwelle": 200000},
|
||||
"callback_base_url": live_server,
|
||||
}
|
||||
if continuation_flow_id is not None:
|
||||
body["continuation_flow_id"] = continuation_flow_id
|
||||
run = (
|
||||
await client.post(f"/api/flows/{flow['id']}/run", json=body)
|
||||
).json()
|
||||
body = {"input": {"betrag": 250000, "schwelle": 200000}}
|
||||
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
|
||||
assert run["status"] == "pending_approval"
|
||||
tasks = (await client.get("/api/approvals")).json()
|
||||
task = next(t for t in tasks if t["run_id"] == run["id"])
|
||||
return run, task
|
||||
|
||||
|
||||
async def test_approve_without_continuation(client, lfx_server, live_server):
|
||||
_, task = await _handoff_task(client, live_server)
|
||||
async def test_approve_without_continuation(client, lfx_server, live_server, login, monkeypatch):
|
||||
await login()
|
||||
_, task = await _handoff_task(client, live_server, monkeypatch)
|
||||
response = await client.post(
|
||||
f"/api/approvals/{task['id']}/decision",
|
||||
json={"decision": "approve", "approver": "sachbearbeiter", "note": "ok"},
|
||||
json={"decision": "approve", "note": "ok"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["task"]["status"] == "approved"
|
||||
assert body["task"]["approver"] == "sachbearbeiter"
|
||||
assert body["task"]["approver"] == "admin@example.com"
|
||||
assert body["task"]["decided_at"] is not None
|
||||
assert body["continuation_run"] is None
|
||||
|
||||
|
||||
async def test_approve_with_continuation_runs_facts(client, lfx_server, live_server):
|
||||
async def test_approve_with_continuation_runs_facts(
|
||||
client, lfx_server, live_server, login, monkeypatch
|
||||
):
|
||||
await login()
|
||||
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
|
||||
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
|
||||
|
||||
_, task = await _handoff_task(
|
||||
client, live_server, continuation_flow_id=cont["id"]
|
||||
# Continuation is authoring-time wiring on the definition (admin-only);
|
||||
# run bodies cannot set it.
|
||||
response = await client.post(
|
||||
"/api/flows",
|
||||
json={"name": f"handoff-{uuid4().hex[:8]}", "continuation_flow_id": cont["id"]},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
flow = response.json()
|
||||
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||
run = (
|
||||
await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={"input": {"betrag": 250000, "schwelle": 200000}},
|
||||
)
|
||||
).json()
|
||||
assert run["status"] == "pending_approval"
|
||||
tasks = (await client.get("/api/approvals")).json()
|
||||
task = next(t for t in tasks if t["run_id"] == run["id"])
|
||||
assert task["continuation_flow_id"] == cont["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/approvals/{task['id']}/decision",
|
||||
json={"decision": "approve", "approver": "chef"},
|
||||
json={"decision": "approve"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
|
|
@ -87,21 +106,51 @@ async def test_approve_with_continuation_runs_facts(client, lfx_server, live_ser
|
|||
assert "Sonderfall" in cont_run["output"]["result"]
|
||||
|
||||
|
||||
async def test_reject_task(client, lfx_server, live_server):
|
||||
_, task = await _handoff_task(client, live_server)
|
||||
async def test_client_continuation_is_ignored(client, lfx_server, live_server, login, monkeypatch):
|
||||
"""A run body cannot chain arbitrary flows (G)."""
|
||||
from app.settings import settings
|
||||
|
||||
await login()
|
||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
|
||||
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
|
||||
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
|
||||
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
||||
run = (
|
||||
await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={
|
||||
"input": {"betrag": 250000, "schwelle": 200000},
|
||||
"continuation_flow_id": cont["id"],
|
||||
},
|
||||
)
|
||||
).json()
|
||||
assert run["status"] == "pending_approval"
|
||||
tasks = (await client.get("/api/approvals")).json()
|
||||
task = next(t for t in tasks if t["run_id"] == run["id"])
|
||||
assert task["continuation_flow_id"] is None
|
||||
|
||||
|
||||
async def test_reject_task(client, lfx_server, live_server, login, monkeypatch):
|
||||
await login()
|
||||
_, task = await _handoff_task(client, live_server, monkeypatch)
|
||||
# Any logged-in user (not just admins) can decide; identity is the session.
|
||||
await login(email="chef@example.com", role="member")
|
||||
response = await client.post(
|
||||
f"/api/approvals/{task['id']}/decision",
|
||||
json={"decision": "reject", "approver": "chef", "note": "zu teuer"},
|
||||
json={"decision": "reject", "note": "zu teuer"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["task"]["status"] == "rejected"
|
||||
assert body["task"]["approver"] == "chef@example.com"
|
||||
assert body["task"]["decision_note"] == "zu teuer"
|
||||
assert body["continuation_run"] is None
|
||||
|
||||
|
||||
async def test_decision_guards(client, lfx_server, live_server):
|
||||
_, task = await _handoff_task(client, live_server)
|
||||
async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch):
|
||||
await login()
|
||||
_, task = await _handoff_task(client, live_server, monkeypatch)
|
||||
url = f"/api/approvals/{task['id']}/decision"
|
||||
|
||||
response = await client.post(url, json={"decision": "approve"})
|
||||
|
|
@ -109,12 +158,10 @@ async def test_decision_guards(client, lfx_server, live_server):
|
|||
response = await client.post(url, json={"decision": "reject"})
|
||||
assert response.status_code == 409
|
||||
|
||||
response = await client.post(
|
||||
f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"}
|
||||
)
|
||||
response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
|
||||
assert response.status_code == 404
|
||||
|
||||
_, task2 = await _handoff_task(client, live_server)
|
||||
_, task2 = await _handoff_task(client, live_server, monkeypatch)
|
||||
response = await client.post(
|
||||
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"}
|
||||
)
|
||||
|
|
@ -128,9 +175,10 @@ async def test_decision_guards(client, lfx_server, live_server):
|
|||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_decision_bad_continuation_reference(client, session):
|
||||
async def test_decision_bad_continuation_reference(client, session, login):
|
||||
from app.flows import store as flow_store
|
||||
|
||||
await login()
|
||||
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}")
|
||||
version = (
|
||||
await client.post(
|
||||
|
|
@ -143,55 +191,55 @@ async def test_decision_bad_continuation_reference(client, session):
|
|||
flow_store.get_version(session, UUID(version["id"])),
|
||||
input={},
|
||||
)
|
||||
task = flow_store.create_approval_task(
|
||||
session, ApprovalCreate(run_id=run.id, title="t")
|
||||
)
|
||||
task = flow_store.create_approval_task(session, ApprovalCreate(run_id=run.id, title="t"))
|
||||
# plant unknown continuation directly (store allows it pre-validation)
|
||||
task.continuation_flow_id = uuid4()
|
||||
session.add(task)
|
||||
session.commit()
|
||||
|
||||
response = await client.post(
|
||||
f"/api/approvals/{task.id}/decision", json={"decision": "approve"}
|
||||
)
|
||||
response = await client.post(f"/api/approvals/{task.id}/decision", json={"decision": "approve"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_tenant_isolation(client, lfx_server):
|
||||
async def test_tenant_isolation(client, lfx_server, login):
|
||||
"""Strict session scoping: explicit ?tenant_id= cannot widen reads (E)."""
|
||||
await login()
|
||||
flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}")
|
||||
await _version_and_deploy(client, flow["id"])
|
||||
|
||||
for tenant in ("pfarramt-a", "pfarramt-b"):
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={"input": {"betrag": 1}, "tenant_id": tenant},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||
assert response.status_code == 200
|
||||
run_a = response.json()
|
||||
assert run_a["tenant_id"] == "pfarramt-a"
|
||||
|
||||
response = await client.get(
|
||||
f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"}
|
||||
)
|
||||
runs_a = response.json()
|
||||
assert len(runs_a) == 1
|
||||
assert runs_a[0]["tenant_id"] == "pfarramt-a"
|
||||
await login(email="b@example.com", role="member", tenant_id="pfarramt-b")
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||
assert response.status_code == 200
|
||||
|
||||
response = await client.get(f"/api/flows/{flow['id']}/runs")
|
||||
assert len(response.json()) == 2
|
||||
# A forged tenant_id query param is ignored: b sees only b's runs.
|
||||
response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
|
||||
runs_b = response.json()
|
||||
assert len(runs_b) == 1
|
||||
assert runs_b[0]["tenant_id"] == "pfarramt-b"
|
||||
|
||||
# Cross-tenant read with scoping 404s.
|
||||
response = await client.get(
|
||||
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-b"}
|
||||
)
|
||||
# Cross-tenant run read 404s with or without the forged param.
|
||||
response = await client.get(f"/api/flows/runs/{run_a['id']}")
|
||||
assert response.status_code == 404
|
||||
response = await client.get(
|
||||
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-a"}
|
||||
f"/api/flows/runs/{run_a['id']}", params={"tenant_id": "pfarramt-a"}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
|
||||
response = await client.get(f"/api/flows/runs/{run_a['id']}")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
|
||||
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login):
|
||||
from app.settings import settings
|
||||
|
||||
await login()
|
||||
flow = await _create_flow(client, f"down-{uuid4().hex[:8]}")
|
||||
await client.post(
|
||||
f"/api/flows/{flow['id']}/versions",
|
||||
|
|
@ -206,28 +254,26 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
|
|||
deployed = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
|
||||
assert deployed.status_code == 200
|
||||
monkeypatch.setattr(settings, "lfx_url", "http://127.0.0.1:9")
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
async def test_lfx_drift_maps_to_502(client, lfx_server):
|
||||
async def test_lfx_drift_maps_to_502(client, lfx_server, login):
|
||||
from app.flows.lfx_client import LfxClient
|
||||
from app.settings import settings
|
||||
|
||||
await login()
|
||||
flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}")
|
||||
deployed = await _version_and_deploy(client, flow["id"])
|
||||
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
|
||||
deployed["lfx_flow_id"]
|
||||
)
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||
assert response.status_code == 502
|
||||
|
||||
|
||||
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
|
||||
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login):
|
||||
await login()
|
||||
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}")
|
||||
bogus = {
|
||||
"name": "bogus",
|
||||
|
|
@ -247,9 +293,7 @@ async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
|
|||
"edges": [],
|
||||
},
|
||||
}
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{flow['id']}/versions", json={"langflow_json": bogus})
|
||||
assert response.status_code == 201, response.text
|
||||
response = await client.post(f"/api/flows/{flow['id']}/versions/1/deploy")
|
||||
assert response.status_code == 422
|
||||
|
|
|
|||
326
backend/tests/test_auth.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
"""Phase 1 tests: magic-link auth, access requests, RBAC, cookie flags."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select
|
||||
|
||||
from app.auth import service
|
||||
from app.auth.deps import SESSION_COOKIE, resolve_tenant
|
||||
from app.models import MagicLinkToken, User
|
||||
from app.settings import settings
|
||||
|
||||
ADMIN = "admin@example.com"
|
||||
MEMBER = "member@example.com"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_smtp_many_admins(monkeypatch):
|
||||
# Never touch the network: dev fallback logs instead of mailing.
|
||||
# (The real repo .env configures SMTP; tests must not use it.)
|
||||
monkeypatch.setattr(settings, "smtp_host", "")
|
||||
monkeypatch.setattr(settings, "admin_emails", ADMIN)
|
||||
|
||||
|
||||
def _get_or_make_user(session, email, role="member"):
|
||||
user = service.get_user_by_email(session, email)
|
||||
if user is None:
|
||||
user = User(email=email, name="Test", role=role)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def _login(client, session, email, role="member"):
|
||||
"""Consume a fresh magic token; persist the session cookie manually.
|
||||
|
||||
httpx honours the Secure cookie flag and would withhold it over the
|
||||
http://test transport, so the session id is re-set as a plain cookie.
|
||||
"""
|
||||
user = _get_or_make_user(session, email, role)
|
||||
token = service.issue_magic_token(session, user)
|
||||
response = await client.post("/api/auth/magic/consume", json={"token": token})
|
||||
assert response.status_code == 200, response.text
|
||||
sid = response.cookies[SESSION_COOKIE]
|
||||
client.cookies.set(SESSION_COOKIE, sid)
|
||||
return response.json()
|
||||
|
||||
|
||||
async def test_request_approve_consume_me_logout(client, session):
|
||||
assert (
|
||||
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
|
||||
).status_code == 201
|
||||
admin = await _login(client, session, ADMIN, role="admin")
|
||||
assert admin["role"] == "admin"
|
||||
|
||||
created = await client.post(
|
||||
"/api/access/requests",
|
||||
json={"name": "Maria", "email": MEMBER, "reason": "choir planning"},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
assert created.json()["status"] == "pending"
|
||||
|
||||
queue = await client.get("/api/access/requests")
|
||||
assert queue.status_code == 200
|
||||
assert any(r["email"] == MEMBER for r in queue.json())
|
||||
|
||||
approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
|
||||
assert approved.status_code == 200, approved.text
|
||||
body = approved.json()
|
||||
assert body["request"]["status"] == "approved"
|
||||
assert body["email_sent"] is False # no SMTP in tests
|
||||
|
||||
# New client (no admin cookie) logs in as the approved member.
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as member:
|
||||
user = service.get_user_by_email(session, MEMBER)
|
||||
assert user is not None and user.role == "member"
|
||||
token = service.issue_magic_token(session, user)
|
||||
consumed = await member.post("/api/auth/magic/consume", json={"token": token})
|
||||
assert consumed.status_code == 200
|
||||
assert consumed.json()["email"] == MEMBER
|
||||
member.cookies.set(SESSION_COOKIE, consumed.cookies[SESSION_COOKIE])
|
||||
|
||||
me = await member.get("/api/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["tenant_id"] == "default"
|
||||
|
||||
assert (await member.post("/api/auth/logout")).status_code == 200
|
||||
assert (await member.get("/api/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_consume_unknown_token_400(client):
|
||||
response = await client.post("/api/auth/magic/consume", json={"token": "nope"})
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
async def test_token_expiry_boundary(client, session):
|
||||
user = _get_or_make_user(session, MEMBER)
|
||||
token = service.issue_magic_token(session, user)
|
||||
record = session.exec(
|
||||
select(MagicLinkToken).where(MagicLinkToken.token_hash == service.hash_token(token))
|
||||
).one()
|
||||
record.expires_at = service.utcnow() - timedelta(seconds=1)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
|
||||
response = await client.post("/api/auth/magic/consume", json={"token": token})
|
||||
assert response.status_code == 400
|
||||
assert "expired" in response.json()["detail"]
|
||||
|
||||
|
||||
async def test_token_reuse_400(client, session):
|
||||
user = _get_or_make_user(session, MEMBER)
|
||||
token = service.issue_magic_token(session, user)
|
||||
assert (await client.post("/api/auth/magic/consume", json={"token": token})).status_code == 200
|
||||
again = await client.post("/api/auth/magic/consume", json={"token": token})
|
||||
assert again.status_code == 400
|
||||
assert "used" in again.json()["detail"]
|
||||
|
||||
|
||||
async def test_admin_only_endpoints(client, session):
|
||||
await _login(client, session, MEMBER, role="member")
|
||||
created = await client.post(
|
||||
"/api/access/requests",
|
||||
json={"name": "X", "email": "x@example.com", "reason": ""},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
assert (await client.get("/api/access/requests")).status_code == 403
|
||||
approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
|
||||
assert approve.status_code == 403
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as anon:
|
||||
assert (await anon.get("/api/access/requests")).status_code == 401
|
||||
assert (
|
||||
await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
|
||||
).status_code == 401
|
||||
assert (await anon.get("/api/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_approve_unknown_and_double_decide(client, session):
|
||||
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
|
||||
await _login(client, session, ADMIN, role="admin")
|
||||
|
||||
assert (await client.post(f"/api/access/requests/{uuid4()}/approve")).status_code == 404
|
||||
|
||||
created = await client.post(
|
||||
"/api/access/requests",
|
||||
json={"name": "Y", "email": "y@example.com", "reason": ""},
|
||||
)
|
||||
rid = created.json()["id"]
|
||||
assert (await client.post(f"/api/access/requests/{rid}/reject")).status_code == 200
|
||||
again = await client.post(f"/api/access/requests/{rid}/approve")
|
||||
assert again.status_code == 409
|
||||
|
||||
|
||||
async def test_bootstrap_once_and_forbidden(client):
|
||||
assert (
|
||||
await client.post("/api/auth/admin-bootstrap", json={"email": "stranger@example.com"})
|
||||
).status_code == 403
|
||||
assert (
|
||||
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
|
||||
).status_code == 201
|
||||
assert (
|
||||
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
|
||||
).status_code == 409
|
||||
|
||||
|
||||
async def test_session_cookie_flags(client, session):
|
||||
user = _get_or_make_user(session, MEMBER)
|
||||
token = service.issue_magic_token(session, user)
|
||||
response = await client.post("/api/auth/magic/consume", json={"token": token})
|
||||
assert response.status_code == 200
|
||||
raw = " ".join(response.headers.get_list("set-cookie")).lower()
|
||||
assert "httponly" in raw
|
||||
assert "secure" in raw
|
||||
assert "samesite=lax" in raw
|
||||
assert "path=/" in raw
|
||||
|
||||
|
||||
async def test_no_token_or_session_in_logs(client, session, caplog):
|
||||
user = _get_or_make_user(session, MEMBER)
|
||||
token = service.issue_magic_token(session, user)
|
||||
with caplog.at_level(logging.INFO):
|
||||
caplog.clear()
|
||||
response = await client.post("/api/auth/magic/consume", json={"token": token})
|
||||
assert response.status_code == 200
|
||||
assert token not in caplog.text
|
||||
assert response.cookies[SESSION_COOKIE] not in caplog.text
|
||||
assert service.hash_token(token) not in caplog.text
|
||||
|
||||
|
||||
async def test_access_request_rate_limit(client):
|
||||
email = f"impatient-{uuid4()}@example.com"
|
||||
for _ in range(service.REQUESTS_PER_EMAIL_PER_HOUR):
|
||||
response = await client.post(
|
||||
"/api/access/requests",
|
||||
json={"name": "I", "email": email, "reason": ""},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
limited = await client.post(
|
||||
"/api/access/requests", json={"name": "I", "email": email, "reason": ""}
|
||||
)
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
def test_resolve_tenant():
|
||||
assert resolve_tenant(None, None) == "default"
|
||||
assert resolve_tenant(None, "acme") == "acme"
|
||||
user = User(email="a@b.c", tenant_id="parish-7")
|
||||
assert resolve_tenant(user, None) == "parish-7"
|
||||
assert resolve_tenant(user, "other") == "parish-7"
|
||||
|
||||
|
||||
def _broken_smtp(monkeypatch):
|
||||
import smtplib
|
||||
|
||||
from app.auth import mail
|
||||
|
||||
class ExplodingSMTP:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise smtplib.SMTPException("relay down")
|
||||
|
||||
monkeypatch.setattr(mail.smtplib, "SMTP", ExplodingSMTP)
|
||||
|
||||
|
||||
def test_magic_link_delivery_failure_returns_false(monkeypatch):
|
||||
from app.auth import mail
|
||||
|
||||
monkeypatch.setattr(settings, "smtp_host", "dummy")
|
||||
_broken_smtp(monkeypatch)
|
||||
assert mail.send_magic_link("a@example.com", "http://x/login?token=t") is False
|
||||
assert mail.send_access_notification("req-1", "A", "a@example.com") is False
|
||||
|
||||
|
||||
async def test_approve_survives_delivery_failure(client, session, monkeypatch):
|
||||
monkeypatch.setattr(settings, "smtp_host", "dummy")
|
||||
_broken_smtp(monkeypatch)
|
||||
await client.post("/api/auth/admin-bootstrap", json={"email": ADMIN})
|
||||
await _login(client, session, ADMIN, role="admin")
|
||||
created = await client.post(
|
||||
"/api/access/requests",
|
||||
json={"name": "M", "email": "m@example.com", "reason": ""},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
approved = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
|
||||
assert approved.status_code == 200, approved.text
|
||||
body = approved.json()
|
||||
assert body["request"]["status"] == "approved"
|
||||
assert body["email_sent"] is False
|
||||
|
||||
|
||||
async def test_magic_request_for_known_user(client, session):
|
||||
_get_or_make_user(session, MEMBER)
|
||||
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["message"] == "login link sent"
|
||||
assert body["email_sent"] is False # no SMTP in tests
|
||||
assert "token" not in response.text
|
||||
tokens = session.exec(
|
||||
select(MagicLinkToken).where(
|
||||
MagicLinkToken.user_id == service.get_user_by_email(session, MEMBER).id
|
||||
)
|
||||
).all()
|
||||
assert len(tokens) == 1
|
||||
|
||||
|
||||
async def test_magic_request_unknown_email_403(client):
|
||||
response = await client.post("/api/auth/magic/request", json={"email": "stranger@example.com"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
async def test_magic_request_admin_email_creates_admin(client, session):
|
||||
response = await client.post("/api/auth/magic/request", json={"email": ADMIN})
|
||||
assert response.status_code == 200, response.text
|
||||
user = service.get_user_by_email(session, ADMIN)
|
||||
assert user is not None and user.role == "admin"
|
||||
|
||||
|
||||
async def test_magic_request_rate_limit(client, session):
|
||||
_get_or_make_user(session, MEMBER)
|
||||
for _ in range(service.MAGIC_LINKS_PER_EMAIL_PER_HOUR):
|
||||
response = await client.post("/api/auth/magic/request", json={"email": MEMBER})
|
||||
assert response.status_code == 200, response.text
|
||||
limited = await client.post("/api/auth/magic/request", json={"email": MEMBER})
|
||||
assert limited.status_code == 429
|
||||
|
||||
|
||||
def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
|
||||
from app.auth import mail
|
||||
|
||||
monkeypatch.setattr(settings, "smtp_host", "dummy")
|
||||
message = mail._branded_mail(
|
||||
subject="Dein Login-Link",
|
||||
to_email="a@example.com",
|
||||
headline="Anmeldung bei Kirche Einfach",
|
||||
intro="Klicke auf den Button.",
|
||||
button_label="Jetzt anmelden",
|
||||
button_url="http://localhost:3000/login?token=GEHEIM",
|
||||
note="Gültig 15 Minuten.",
|
||||
)
|
||||
html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
|
||||
html = html_part.get_content()
|
||||
# Button = anchor with href around the label; token never in text-only form.
|
||||
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
|
||||
assert ">Jetzt anmelden</a>" in html
|
||||
assert mail.BLUE in html # kirchenblau brand bar
|
||||
assert 'lang="de"' in html
|
||||
# Multipart: plain-text fallback keeps the raw link.
|
||||
parts = [p.get_content_type() for p in message.walk()]
|
||||
assert "text/plain" in parts and "text/html" in parts
|
||||
plain = next(p.get_content() for p in message.walk() if p.get_content_type() == "text/plain")
|
||||
assert "http://localhost:3000/login?token=GEHEIM" in plain
|
||||
79
backend/tests/test_check_prod.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Prod env checker: failures block deploys, warnings don't."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "scripts"))
|
||||
|
||||
from check_prod import check
|
||||
|
||||
|
||||
def _good() -> dict:
|
||||
return {
|
||||
"CALLBACK_TOKEN": "t" * 40,
|
||||
"LANGFLOW_API_KEY": "k" * 24,
|
||||
"LFX_API_KEY": "x" * 24,
|
||||
"LANGFLOW_SUPERUSER_PASSWORD": "p" * 20,
|
||||
"LANGFLOW_SECRET_KEY": "s" * 40,
|
||||
"FRONTEND_MODE": "preview",
|
||||
"ADMIN_EMAILS": "admin@example.com",
|
||||
"SMTP_HOST": "smtp.example.com",
|
||||
"SMTP_PASSWORD": "secret",
|
||||
"CONTACT_TO": "team@example.com",
|
||||
"FRONTEND_URL": "https://example.com",
|
||||
}
|
||||
|
||||
|
||||
def test_good_env_passes_without_warnings():
|
||||
failures, warnings = check(_good())
|
||||
assert failures == []
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_empty_and_short_secrets_fail():
|
||||
values = _good()
|
||||
values["CALLBACK_TOKEN"] = ""
|
||||
values["LANGFLOW_SUPERUSER_PASSWORD"] = "short"
|
||||
failures, _ = check(values)
|
||||
assert any("CALLBACK_TOKEN" in f for f in failures)
|
||||
assert any("LANGFLOW_SUPERUSER_PASSWORD" in f for f in failures)
|
||||
|
||||
|
||||
def test_placeholders_fail():
|
||||
values = _good()
|
||||
values["LANGFLOW_API_KEY"] = "sk-"
|
||||
values["LFX_API_KEY"] = "lfx-abc"
|
||||
failures, _ = check(values)
|
||||
assert any("LANGFLOW_API_KEY" in f for f in failures)
|
||||
assert any("LFX_API_KEY" in f for f in failures)
|
||||
|
||||
|
||||
def test_insecure_modes_fail():
|
||||
values = _good()
|
||||
values["FRONTEND_MODE"] = "dev"
|
||||
values["APP_ENV"] = "dev"
|
||||
values["LANGFLOW_AUTO_LOGIN"] = "true"
|
||||
failures, _ = check(values)
|
||||
assert len([f for f in failures if "FRONTEND_MODE" in f]) == 1
|
||||
assert len([f for f in failures if "APP_ENV" in f]) == 1
|
||||
assert len([f for f in failures if "AUTO_LOGIN" in f]) == 1
|
||||
|
||||
|
||||
def test_soft_misconfigurations_warn_only():
|
||||
values = _good()
|
||||
values["ADMIN_EMAILS"] = ""
|
||||
values["SMTP_PASSWORD"] = ""
|
||||
values["CONTACT_TO"] = ""
|
||||
values["FRONTEND_URL"] = "http://example.com"
|
||||
failures, warnings = check(values)
|
||||
assert failures == []
|
||||
assert len(warnings) == 4
|
||||
|
||||
|
||||
def test_example_file_would_not_pass():
|
||||
from check_prod import parse_env
|
||||
|
||||
values = parse_env(REPO_ROOT / ".env.example")
|
||||
failures, _ = check(values)
|
||||
assert failures, "placeholders must be detectable"
|
||||
|
|
@ -25,9 +25,10 @@ def _export_doc(name="importiert"):
|
|||
|
||||
|
||||
async def test_import_creates_definition_version_and_deploys(
|
||||
client, lfx_server, override_langflow
|
||||
client, login, lfx_server, override_langflow
|
||||
):
|
||||
override_langflow(_export_doc())
|
||||
await login()
|
||||
response = await client.post(
|
||||
"/api/flows/import",
|
||||
json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"},
|
||||
|
|
@ -37,31 +38,22 @@ async def test_import_creates_definition_version_and_deploys(
|
|||
assert body["definition"]["name"] == "importiert"
|
||||
assert body["version"]["version_no"] == 1
|
||||
assert body["version"]["author"] == "admin"
|
||||
assert body["version"]["lfx_flow_id"] == lfx_id_for(
|
||||
UUID(body["definition"]["id"]), 1
|
||||
)
|
||||
assert body["version"]["lfx_flow_id"] == lfx_id_for(UUID(body["definition"]["id"]), 1)
|
||||
assert body["version"]["deployed_at"] is not None
|
||||
assert body["version"]["langflow_json"]["data"]["nodes"]
|
||||
|
||||
# Imported flow actually runs on LFX.
|
||||
definition_id = body["definition"]["id"]
|
||||
response = await client.post(
|
||||
f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}})
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["status"] == "completed"
|
||||
|
||||
|
||||
async def test_import_same_content_is_idempotent(
|
||||
client, lfx_server, override_langflow
|
||||
):
|
||||
async def test_import_same_content_is_idempotent(client, login, lfx_server, override_langflow):
|
||||
override_langflow(_export_doc())
|
||||
first = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
second = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
await login()
|
||||
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 201
|
||||
assert first.json()["definition"]["id"] == second.json()["definition"]["id"]
|
||||
|
|
@ -69,42 +61,37 @@ async def test_import_same_content_is_idempotent(
|
|||
assert second.json()["version"]["version_no"] == 1
|
||||
|
||||
|
||||
async def test_import_changed_content_bumps_version(
|
||||
client, lfx_server, override_langflow
|
||||
):
|
||||
async def test_import_changed_content_bumps_version(client, login, lfx_server, override_langflow):
|
||||
override_langflow(_export_doc())
|
||||
first = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
await login()
|
||||
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert first.status_code == 201
|
||||
changed = _export_doc()
|
||||
changed["description"] = "neue Beschreibung"
|
||||
override_langflow(changed)
|
||||
second = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert second.status_code == 201
|
||||
assert second.json()["version"]["version_no"] == 2
|
||||
|
||||
|
||||
async def test_import_writes_canonical_file(
|
||||
client, lfx_server, override_langflow, tmp_path, monkeypatch
|
||||
client, login, lfx_server, override_langflow, tmp_path, monkeypatch
|
||||
):
|
||||
from app.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||
override_langflow(_export_doc())
|
||||
response = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
await login()
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert response.status_code == 201, response.text
|
||||
written = tmp_path / "importiert.json"
|
||||
assert written.is_file()
|
||||
assert json.loads(written.read_text())["name"] == "importiert"
|
||||
|
||||
|
||||
async def test_import_with_explicit_name(client, lfx_server, override_langflow):
|
||||
async def test_import_with_explicit_name(client, login, lfx_server, override_langflow):
|
||||
override_langflow(_export_doc(name="langflow-name"))
|
||||
await login()
|
||||
response = await client.post(
|
||||
"/api/flows/import",
|
||||
json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"},
|
||||
|
|
@ -113,8 +100,9 @@ async def test_import_with_explicit_name(client, lfx_server, override_langflow):
|
|||
assert response.json()["definition"]["name"] == "wunschname"
|
||||
|
||||
|
||||
async def test_import_without_deploy(client, override_langflow):
|
||||
async def test_import_without_deploy(client, login, override_langflow):
|
||||
override_langflow(_export_doc())
|
||||
await login()
|
||||
response = await client.post(
|
||||
"/api/flows/import",
|
||||
json={"langflow_flow_id": LANGFLOW_ID, "deploy": False},
|
||||
|
|
@ -125,24 +113,23 @@ async def test_import_without_deploy(client, override_langflow):
|
|||
assert body["version"]["deployed_at"] is None
|
||||
|
||||
|
||||
async def test_import_langflow_not_found(client, override_langflow):
|
||||
async def test_import_langflow_not_found(client, login, override_langflow):
|
||||
override_langflow(status=404)
|
||||
response = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
await login()
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_import_langflow_unreachable(client, override_langflow):
|
||||
async def test_import_langflow_unreachable(client, login, override_langflow):
|
||||
override_langflow(status=0)
|
||||
response = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": str(uuid4())}
|
||||
)
|
||||
await login()
|
||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
async def test_list_langflow_flows(client, override_langflow):
|
||||
async def test_list_langflow_flows(client, login, override_langflow):
|
||||
override_langflow(_export_doc())
|
||||
await login()
|
||||
response = await client.get("/api/flows/langflow/available")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [{"id": "fake-1", "name": "fake"}]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from uuid import UUID, uuid4
|
|||
from app.flows.sync import lfx_id_for
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
|
||||
|
||||
|
||||
def load_fixture(name: str) -> dict:
|
||||
|
|
@ -14,9 +15,7 @@ def load_fixture(name: str) -> dict:
|
|||
|
||||
|
||||
async def _create_flow(client, name="regeltest"):
|
||||
response = await client.post(
|
||||
"/api/flows", json={"name": name, "description": "test flow"}
|
||||
)
|
||||
response = await client.post("/api/flows", json={"name": name, "description": "test flow"})
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
|
@ -36,7 +35,8 @@ async def _deploy(client, flow_id, version_no=1):
|
|||
return response.json()
|
||||
|
||||
|
||||
async def test_flow_crud(client):
|
||||
async def test_flow_crud(client, login):
|
||||
await login()
|
||||
created = await _create_flow(client, "crud-flow")
|
||||
assert created["status"] == "active"
|
||||
|
||||
|
|
@ -55,7 +55,63 @@ async def test_flow_crud(client):
|
|||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_version_validation_and_numbering(client):
|
||||
async def test_management_endpoints_require_admin(client, login):
|
||||
"""Anonymous -> 401, member -> 403, admin -> through (A)."""
|
||||
flow_id = uuid4()
|
||||
probes = [
|
||||
("POST", "/api/flows", {"name": "x"}),
|
||||
("POST", "/api/flows/sync", None),
|
||||
("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}),
|
||||
("GET", f"/api/flows/{flow_id}/versions", None),
|
||||
("POST", f"/api/flows/{flow_id}/versions/1/deploy", None),
|
||||
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
|
||||
("GET", "/api/flows/langflow/available", None),
|
||||
]
|
||||
for method, path, body in probes:
|
||||
if method == "POST":
|
||||
response = await client.post(path, json=body or {})
|
||||
else:
|
||||
response = await client.get(path)
|
||||
assert response.status_code == 401, (method, path, response.text)
|
||||
|
||||
await login(email="member@example.com", role="member")
|
||||
for method, path, body in probes:
|
||||
if method == "POST":
|
||||
response = await client.post(path, json=body or {})
|
||||
else:
|
||||
response = await client.get(path)
|
||||
assert response.status_code == 403, (method, path, response.text)
|
||||
|
||||
|
||||
async def test_runs_and_reads_require_login(client, login):
|
||||
"""Run, run reads and approval reads are login-gated (A)."""
|
||||
await login()
|
||||
flow = await _create_flow(client, "gated")
|
||||
run_id = uuid4()
|
||||
authed_paths = [
|
||||
("POST", f"/api/flows/{flow['id']}/run", {"input": {}}),
|
||||
("GET", f"/api/flows/{flow['id']}/runs", None),
|
||||
("GET", f"/api/flows/runs/{run_id}", None),
|
||||
("GET", "/api/approvals", None),
|
||||
("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}),
|
||||
]
|
||||
# Fresh client without a session cookie.
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as anon:
|
||||
for method, path, body in authed_paths:
|
||||
if method == "POST":
|
||||
response = await anon.post(path, json=body or {})
|
||||
else:
|
||||
response = await anon.get(path)
|
||||
assert response.status_code == 401, (method, path, response.text)
|
||||
|
||||
|
||||
async def test_version_validation_and_numbering(client, login):
|
||||
await login()
|
||||
flow = await _create_flow(client, "versioned")
|
||||
|
||||
v1 = await _create_version(client, flow["id"])
|
||||
|
|
@ -79,7 +135,8 @@ async def test_version_validation_and_numbering(client):
|
|||
assert [v["version_no"] for v in response.json()] == [1, 2]
|
||||
|
||||
|
||||
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
|
||||
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login):
|
||||
await login()
|
||||
flow = await _create_flow(client, "dachsanierung")
|
||||
await _create_version(client, flow["id"])
|
||||
deployed = await _deploy(client, flow["id"])
|
||||
|
|
@ -110,16 +167,16 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
|
|||
assert response.json()["id"] == run["id"]
|
||||
|
||||
|
||||
async def test_run_undeployed_version_fails(client):
|
||||
async def test_run_undeployed_version_fails(client, login):
|
||||
await login()
|
||||
flow = await _create_flow(client, "undeployed")
|
||||
await _create_version(client, flow["id"])
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
|
||||
)
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_run_explicit_version_no(client, lfx_server):
|
||||
async def test_run_explicit_version_no(client, lfx_server, login):
|
||||
await login()
|
||||
flow = await _create_flow(client, "explicit-version")
|
||||
await _create_version(client, flow["id"])
|
||||
await _create_version(client, flow["id"])
|
||||
|
|
@ -132,7 +189,8 @@ async def test_run_explicit_version_no(client, lfx_server):
|
|||
assert response.json()["status"] == "completed"
|
||||
|
||||
|
||||
async def test_run_stream_events(client, lfx_server):
|
||||
async def test_run_stream_events(client, lfx_server, login):
|
||||
await login()
|
||||
flow = await _create_flow(client, "streamed")
|
||||
await _create_version(client, flow["id"])
|
||||
await _deploy(client, flow["id"])
|
||||
|
|
@ -165,17 +223,18 @@ async def test_run_stream_events(client, lfx_server):
|
|||
assert response.json()["status"] == "completed"
|
||||
|
||||
|
||||
async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
|
||||
async def test_approval_handoff_end_to_end(client, lfx_server, live_server, login, monkeypatch):
|
||||
from app.settings import settings
|
||||
|
||||
await login()
|
||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||
flow = await _create_flow(client, "freigabe")
|
||||
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
||||
await _deploy(client, flow["id"])
|
||||
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={
|
||||
"input": {"betrag": 250000, "schwelle": 200000},
|
||||
"callback_base_url": live_server,
|
||||
},
|
||||
json={"input": {"betrag": 250000, "schwelle": 200000}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
run = response.json()
|
||||
|
|
@ -192,17 +251,20 @@ async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
|
|||
assert task["payload"]["facts"]["betrag"] == 250000
|
||||
|
||||
|
||||
async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server):
|
||||
async def test_approval_handoff_auto_creates_no_task(
|
||||
client, lfx_server, live_server, login, monkeypatch
|
||||
):
|
||||
from app.settings import settings
|
||||
|
||||
await login()
|
||||
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||
flow = await _create_flow(client, "freigabe-auto")
|
||||
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
||||
await _deploy(client, flow["id"])
|
||||
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={
|
||||
"input": {"betrag": 50000, "schwelle": 200000},
|
||||
"callback_base_url": live_server,
|
||||
},
|
||||
json={"input": {"betrag": 50000, "schwelle": 200000}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["status"] == "completed"
|
||||
|
|
@ -212,11 +274,12 @@ async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_se
|
|||
assert response.json() == []
|
||||
|
||||
|
||||
async def test_approval_callback_contract(client, session):
|
||||
async def test_approval_callback_contract(client, session, login):
|
||||
from uuid import UUID
|
||||
|
||||
from app.flows import store as flow_store
|
||||
|
||||
await login()
|
||||
flow = await _create_flow(client, "callback-target")
|
||||
version = await _create_version(client, flow["id"])
|
||||
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
||||
|
|
@ -226,6 +289,7 @@ async def test_approval_callback_contract(client, session):
|
|||
response = await client.post(
|
||||
"/api/approvals",
|
||||
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
|
||||
headers=CALLBACK_HEADERS,
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
task = response.json()
|
||||
|
|
@ -233,18 +297,46 @@ async def test_approval_callback_contract(client, session):
|
|||
assert task["tenant_id"] == "default"
|
||||
assert task["version_id"] == version["id"]
|
||||
|
||||
response = await client.get(f"/api/flows/runs/{run.id}")
|
||||
response = await client.get(f"/api/flows/runs/{run.id}", headers=CALLBACK_HEADERS)
|
||||
assert response.json()["status"] == "pending_approval"
|
||||
|
||||
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
|
||||
response = await client.post(
|
||||
"/api/approvals", json={"run_id": str(run.id)}, headers=CALLBACK_HEADERS
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
||||
response = await client.post(
|
||||
"/api/approvals",
|
||||
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
|
||||
headers=CALLBACK_HEADERS,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
response = await client.post("/api/approvals", json={"run_id": str(uuid4())})
|
||||
response = await client.post(
|
||||
"/api/approvals", json={"run_id": str(uuid4())}, headers=CALLBACK_HEADERS
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_approval_callback_token_required(client, session, login):
|
||||
"""Forged callbacks without the shared token are rejected (C)."""
|
||||
from uuid import UUID
|
||||
|
||||
from app.flows import store as flow_store
|
||||
|
||||
await login()
|
||||
flow = await _create_flow(client, "callback-gated")
|
||||
version = await _create_version(client, flow["id"])
|
||||
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
||||
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
||||
|
||||
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
|
||||
assert response.status_code == 401
|
||||
|
||||
response = await client.post(
|
||||
"/api/approvals",
|
||||
json={"run_id": str(run.id)},
|
||||
headers={"X-Callback-Token": "wrong"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
|
|
|||
105
backend/tests/test_form_components.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Phase 2 tests: kirche CustomInput* library + intake fixture flow."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from lfx.custom import Component
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||
|
||||
from kirche.form_inputs import CustomInputForm
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
|
||||
FORM_FIELDS = [
|
||||
"full_name",
|
||||
"notes",
|
||||
"password",
|
||||
"age",
|
||||
"amount",
|
||||
"newsletter",
|
||||
"department",
|
||||
"topics",
|
||||
"satisfaction",
|
||||
"attachment",
|
||||
]
|
||||
|
||||
|
||||
def test_component_contract():
|
||||
assert issubclass(CustomInputForm, Component)
|
||||
assert CustomInputForm.display_name.startswith("CustomInput")
|
||||
inputs = {i.name: i for i in CustomInputForm.inputs}
|
||||
# Ten form fields plus the hidden run-envelope plumbing input.
|
||||
assert sorted(n for n in inputs if n != "form_values") == sorted(FORM_FIELDS)
|
||||
assert inputs["form_values"].show is False
|
||||
outputs = {o.name: o for o in CustomInputForm.outputs}
|
||||
assert set(outputs) == {"form_data"}
|
||||
|
||||
|
||||
def test_collect_merges_submitted_values_over_defaults():
|
||||
from lfx.schema.message import Message
|
||||
|
||||
form = CustomInputForm()
|
||||
default = json.loads(form.collect().text)
|
||||
assert default["full_name"] == ""
|
||||
assert default["department"] == "Music"
|
||||
assert isinstance(form.collect(), Message)
|
||||
|
||||
form.form_values = json.dumps({"full_name": "Maria", "age": 41})
|
||||
merged = json.loads(form.collect().text)
|
||||
assert merged["full_name"] == "Maria"
|
||||
assert merged["age"] == 41
|
||||
assert merged["department"] == "Music" # default kept
|
||||
|
||||
# Run envelope shape {"payload": {...}} unwraps like the other components.
|
||||
form.form_values = json.dumps({"payload": {"notes": "hi"}})
|
||||
assert json.loads(form.collect().text)["notes"] == "hi"
|
||||
|
||||
form.form_values = "not json"
|
||||
assert json.loads(form.collect().text)["full_name"] == ""
|
||||
|
||||
|
||||
def test_intake_fixture_shape():
|
||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
||||
form = nodes["CustomInputForm-intake"]
|
||||
assert form["data"]["type"] == "CustomInputForm"
|
||||
assert form["data"]["node"]["display_name"].startswith("CustomInput")
|
||||
template = form["data"]["node"]["template"]
|
||||
for field in FORM_FIELDS:
|
||||
assert field in template, field
|
||||
assert template["form_values"]["show"] is False
|
||||
assert len(template["code"]["value"]) > 200
|
||||
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
|
||||
assert ("ChatInput-entry", "CustomInputForm-intake") in edges
|
||||
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
|
||||
|
||||
|
||||
async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
|
||||
await login()
|
||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||
created = await client.post("/api/flows", json={"name": "intake-e2e"})
|
||||
assert created.status_code == 201, created.text
|
||||
flow_id = created.json()["id"]
|
||||
|
||||
version = await client.post(
|
||||
f"/api/flows/{flow_id}/versions",
|
||||
json={"langflow_json": doc, "author": "test"},
|
||||
)
|
||||
assert version.status_code == 201, version.text
|
||||
|
||||
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
||||
assert deployed.status_code == 200, deployed.text
|
||||
|
||||
run = await client.post(
|
||||
f"/api/flows/{flow_id}/run",
|
||||
json={"input": {"full_name": "Maria Muster", "age": 41}},
|
||||
)
|
||||
assert run.status_code == 200, run.text
|
||||
assert run.json()["status"] == "completed"
|
||||
result = json.loads(run.json()["output"]["result"])
|
||||
assert result["full_name"] == "Maria Muster"
|
||||
assert result["age"] == 41
|
||||
assert result["department"] == "Music" # default preserved
|
||||
194
backend/tests/test_input_schema.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
"""Phase 3 tests: form-schema extraction, sync cache, input-schema endpoint."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from app.flows import input_schema as parser
|
||||
from app.flows import sync
|
||||
from app.flows.lfx_client import LfxClient
|
||||
from app.settings import settings
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
|
||||
|
||||
def _field(name, itype, **kw):
|
||||
base = {
|
||||
"name": name,
|
||||
"display_name": name.replace("_", " ").title(),
|
||||
"info": "",
|
||||
"value": None,
|
||||
"required": False,
|
||||
"advanced": False,
|
||||
"show": True,
|
||||
"_input_type": itype,
|
||||
}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"):
|
||||
template = {f["name"]: f for f in fields}
|
||||
node = {
|
||||
"data": {
|
||||
"type": node_type,
|
||||
"node": {"display_name": display, "template": template},
|
||||
}
|
||||
}
|
||||
return {"data": {"nodes": [node], "edges": []}}
|
||||
|
||||
|
||||
def test_type_mapping_table():
|
||||
doc = _doc(
|
||||
_field("a", "StrInput", value="x"),
|
||||
_field("b", "MessageTextInput"),
|
||||
_field("c", "MultilineInput"),
|
||||
_field("d", "SecretStrInput"),
|
||||
_field("e", "IntInput", value=3),
|
||||
_field("f", "FloatInput", value=1.5),
|
||||
_field("g", "BoolInput", value=True),
|
||||
_field(
|
||||
"h",
|
||||
"DropdownInput",
|
||||
options=["Music", "Youth"],
|
||||
value="Youth",
|
||||
),
|
||||
_field(
|
||||
"i",
|
||||
"MultiselectInput",
|
||||
options=["Worship", "Courses"],
|
||||
value=["Worship"],
|
||||
),
|
||||
_field(
|
||||
"j",
|
||||
"SliderInput",
|
||||
value=3,
|
||||
range_spec={"min": 1, "max": 5, "step": 1},
|
||||
),
|
||||
_field("k", "FileInput"),
|
||||
_field("l", "FutureFancyInput"),
|
||||
)
|
||||
schema = parser.extract_form_schema(doc)
|
||||
assert "fallback" not in schema
|
||||
props = schema["json_schema"]["properties"]
|
||||
assert props["a"] == {"title": "A", "type": "string", "default": "x"}
|
||||
assert props["b"]["type"] == "string"
|
||||
assert props["e"] == {"title": "E", "type": "integer", "default": 3}
|
||||
assert props["f"]["type"] == "number"
|
||||
assert props["g"] == {"title": "G", "type": "boolean", "default": True}
|
||||
assert props["h"] == {
|
||||
"title": "H",
|
||||
"type": "string",
|
||||
"enum": ["Music", "Youth"],
|
||||
"default": "Youth",
|
||||
}
|
||||
assert props["i"]["type"] == "array"
|
||||
assert props["i"]["items"] == {"type": "string", "enum": ["Worship", "Courses"]}
|
||||
assert props["i"]["uniqueItems"] is True
|
||||
assert props["j"]["minimum"] == 1
|
||||
assert props["j"]["maximum"] == 5
|
||||
assert props["j"]["multipleOf"] == 1
|
||||
assert props["k"]["type"] == "string"
|
||||
assert props["l"]["type"] == "string"
|
||||
ui = schema["ui_schema"]
|
||||
assert ui["c"] == {"ui:widget": "textarea"}
|
||||
assert ui["d"] == {"ui:widget": "password"}
|
||||
assert ui["j"] == {"ui:widget": "range"}
|
||||
|
||||
|
||||
def test_dropdown_default_outside_enum_omitted():
|
||||
doc = _doc(_field("h", "DropdownInput", options=["Music"], value=""))
|
||||
props = parser.extract_form_schema(doc)["json_schema"]["properties"]
|
||||
assert props["h"] == {"title": "H", "type": "string", "enum": ["Music"]}
|
||||
|
||||
|
||||
def test_required_advanced_hidden_code():
|
||||
doc = _doc(
|
||||
_field("a", "StrInput", required=True),
|
||||
_field("b", "StrInput", advanced=True),
|
||||
_field("c", "StrInput", show=False),
|
||||
)
|
||||
doc["data"]["nodes"][0]["data"]["node"]["template"]["code"] = {"value": "x"}
|
||||
schema = parser.extract_form_schema(doc)
|
||||
assert schema["json_schema"]["required"] == ["a"]
|
||||
assert set(schema["json_schema"]["properties"]) == {"a", "b"}
|
||||
assert schema["ui_schema"]["ui:order"] == ["a", "b", "*"]
|
||||
|
||||
|
||||
def test_display_name_match_and_fallback():
|
||||
doc = _doc(_field("a", "StrInput"), node_type="Custom", display="CustomInput X")
|
||||
assert set(parser.extract_form_schema(doc)["json_schema"]["properties"]) == {"a"}
|
||||
plain = {
|
||||
"data": {
|
||||
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
|
||||
"edges": [],
|
||||
}
|
||||
}
|
||||
schema = parser.extract_form_schema(plain)
|
||||
assert schema["fallback"] == "textarea"
|
||||
assert schema["json_schema"]["properties"] == {}
|
||||
|
||||
|
||||
def test_real_intake_fixture_parses():
|
||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||
schema = parser.extract_form_schema(doc)
|
||||
props = schema["json_schema"]["properties"]
|
||||
assert "fallback" not in schema
|
||||
assert set(props) == {
|
||||
"full_name",
|
||||
"notes",
|
||||
"password",
|
||||
"age",
|
||||
"amount",
|
||||
"newsletter",
|
||||
"department",
|
||||
"topics",
|
||||
"satisfaction",
|
||||
"attachment",
|
||||
}
|
||||
assert "full_name" in schema["json_schema"]["required"]
|
||||
assert props["department"]["enum"] == ["Music", "Youth", "Seniors", "Admin"]
|
||||
assert schema["ui_schema"]["notes"] == {"ui:widget": "textarea"}
|
||||
|
||||
|
||||
def _lfx() -> LfxClient:
|
||||
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
||||
|
||||
|
||||
async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
|
||||
(tmp_path / "intake_form.json").write_text((FIXTURES / "intake_form.json").read_text())
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert not report.errors, report.errors
|
||||
from app.flows import store
|
||||
|
||||
defs = store.list_definitions(session)
|
||||
definition = next(d for d in defs if d.name == "intake_form")
|
||||
versions = store.list_versions(session, definition.id)
|
||||
assert len(versions) == 1
|
||||
cached = versions[0].input_schema
|
||||
assert cached["json_schema"]["properties"]["full_name"]["type"] == "string"
|
||||
assert "form_values" not in cached["json_schema"]["properties"]
|
||||
|
||||
|
||||
async def test_input_schema_endpoint(client, lfx_server, login):
|
||||
assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404
|
||||
|
||||
await login()
|
||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||
created = await client.post("/api/flows", json={"name": "schema-e2e"})
|
||||
flow_id = created.json()["id"]
|
||||
version = await client.post(f"/api/flows/{flow_id}/versions", json={"langflow_json": doc})
|
||||
assert version.status_code == 201, version.text
|
||||
assert version.json()["input_schema"]["json_schema"]["properties"]["age"]["type"] == "integer"
|
||||
# Created but never deployed → 404.
|
||||
assert (await client.get(f"/api/flows/{flow_id}/input-schema")).status_code == 404
|
||||
|
||||
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
||||
assert deployed.status_code == 200, deployed.text
|
||||
response = await client.get(f"/api/flows/{flow_id}/input-schema")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["version_no"] == 1
|
||||
assert body["fallback"] is None
|
||||
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
|
||||
assert body["ui_schema"]["password"] == {"ui:widget": "password"}
|
||||
|
|
@ -20,9 +20,7 @@ def _client():
|
|||
@respx.mock
|
||||
async def test_export_flow_sends_api_key_and_returns_doc():
|
||||
doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}}
|
||||
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(
|
||||
return_value=httpx.Response(200, json=doc)
|
||||
)
|
||||
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(return_value=httpx.Response(200, json=doc))
|
||||
result = await _client().export_flow("abc")
|
||||
assert result == doc
|
||||
assert route.called
|
||||
|
|
|
|||
33
backend/tests/test_lfx_validate.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Canonical flows must pass lfx's strict validator (level 4).
|
||||
|
||||
Guards the source-of-truth directory against authoring mistakes: the
|
||||
input_schema parser and the LFX runtime both assume well-formed exports.
|
||||
Runs `lfx validate --level 4` (the dev dep, same minor as Langflow).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
|
||||
|
||||
|
||||
def test_canonical_flows_pass_lfx_validate():
|
||||
files = sorted(FLOW_DIR.glob("*.json"))
|
||||
assert files, "no canonical flows found"
|
||||
process = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"lfx",
|
||||
"validate",
|
||||
"--level",
|
||||
"4",
|
||||
*[str(f) for f in files],
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False,
|
||||
)
|
||||
assert process.returncode == 0, f"lfx validate failed:\n{process.stdout}\n{process.stderr}"
|
||||
85
backend/tests/test_middleware.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Edge hardening: docs off, headers, body cap, run throttle, mail scrub."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.middleware import reset_throttle
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_throttle():
|
||||
reset_throttle()
|
||||
yield
|
||||
reset_throttle()
|
||||
|
||||
|
||||
async def test_docs_disabled_outside_debug(client):
|
||||
assert (await client.get("/api/docs")).status_code == 404
|
||||
assert (await client.get("/api/redoc")).status_code == 404
|
||||
assert (await client.get("/api/openapi.json")).status_code == 404
|
||||
|
||||
|
||||
async def test_security_headers_present(client):
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert response.headers["X-Frame-Options"] == "SAMEORIGIN"
|
||||
assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin"
|
||||
assert response.headers["Content-Security-Policy"] == "frame-ancestors 'self'"
|
||||
|
||||
|
||||
async def test_oversize_body_rejected(client):
|
||||
# Default cap is 2 MiB (settings.max_body_bytes); the middleware binds
|
||||
# it at startup, so this test exceeds the real default.
|
||||
big = "x" * (3 * 1024 * 1024)
|
||||
response = await client.post("/api/auth/magic/consume", json={"token": big})
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
|
||||
from app.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "run_throttle_per_minute", 2)
|
||||
await login()
|
||||
|
||||
flow = (await client.post("/api/flows", json={"name": "throttled"})).json()
|
||||
await client.post(
|
||||
f"/api/flows/{flow['id']}/versions",
|
||||
json={
|
||||
"langflow_json": {
|
||||
"name": "t",
|
||||
"data": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "ChatInput-x",
|
||||
"data": {
|
||||
"id": "ChatInput-x",
|
||||
"type": "ChatInput",
|
||||
"node": {"template": {}, "outputs": []},
|
||||
},
|
||||
"type": "genericNode",
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
# Undeployed: 422s still pass the throttle (only 429 matters here).
|
||||
for _ in range(2):
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
|
||||
assert response.status_code == 422
|
||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
|
||||
assert response.status_code == 429
|
||||
assert response.headers["Retry-After"]
|
||||
|
||||
|
||||
def test_magic_link_never_logged(caplog, monkeypatch):
|
||||
from app.auth import mail
|
||||
from app.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "smtp_host", "")
|
||||
with caplog.at_level("INFO", logger="app.auth.mail"):
|
||||
assert mail.send_magic_link("a@example.com", "http://x/login?token=SECRET") is False
|
||||
assert "SECRET" not in caplog.text
|
||||
assert "http://x/login" not in caplog.text
|
||||
assert "a@example.com" in caplog.text
|
||||
|
|
@ -5,7 +5,6 @@ import json
|
|||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -119,7 +118,8 @@ async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path
|
|||
assert definition.name == "umbenannt"
|
||||
|
||||
|
||||
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch):
|
||||
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch, login):
|
||||
await login()
|
||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||
_seed_dir(tmp_path)
|
||||
response = await client.post("/api/flows/sync")
|
||||
|
|
|
|||
|
|
@ -12,10 +12,17 @@ services:
|
|||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
|
||||
# containers must still create so `config`/`build` work everywhere.
|
||||
# Runtime secrets come from `.env` when present (see setup.sh).
|
||||
- path: .env
|
||||
required: false
|
||||
volumes:
|
||||
# No venv volume by design: the venv lives at /opt/venv (image-local,
|
||||
# see backend/Dockerfile). A named volume here used to shadow the image
|
||||
# content with an empty dir on first start (split-brain with the host
|
||||
# checkout, plus root-owned interpreters).
|
||||
- .:/srv/kirche-einfach
|
||||
- kirche-venv:/srv/kirche-einfach/backend/.venv
|
||||
ports:
|
||||
- "8000:8000" # FastAPI
|
||||
depends_on:
|
||||
|
|
@ -40,13 +47,20 @@ services:
|
|||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
|
||||
# containers must still create so `config`/`build` work everywhere.
|
||||
# Runtime secrets come from `.env` when present (see setup.sh).
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
# Dev default (HMR); prod .env sets `preview` (build + serve bundle).
|
||||
FRONTEND_MODE: ${FRONTEND_MODE:-dev}
|
||||
# Fail closed: unset means the production preview server. Dev sets
|
||||
# FRONTEND_MODE=dev in .env (provisioned by .devcontainer/setup.sh).
|
||||
FRONTEND_MODE: ${FRONTEND_MODE:-preview}
|
||||
volumes:
|
||||
# No node_modules volume by design: deps live in the bind-mount and the
|
||||
# entrypoint runs `npm ci` on first start (a volume used to shadow the
|
||||
# image install with an empty dir and diverge from the host checkout).
|
||||
- .:/srv/kirche-einfach
|
||||
- kirche-node-modules:/srv/kirche-einfach/frontend/node_modules
|
||||
ports:
|
||||
- "3000:3000" # Vite
|
||||
depends_on:
|
||||
|
|
@ -55,9 +69,16 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
langflow:
|
||||
image: langflowai/langflow:latest
|
||||
# Pinned (never :latest): Langflow and LFX are versioned together, their
|
||||
# major.minor must match. Bump via LANGFLOW_VERSION in .env (see
|
||||
# .env.example) together with Dockerfile.lfx + backend `lfx~=` pin.
|
||||
image: langflowai/langflow:${LANGFLOW_VERSION:-1.12.0}
|
||||
env_file:
|
||||
- .env
|
||||
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
|
||||
# containers must still create so `config`/`build` work everywhere.
|
||||
# Runtime secrets come from `.env` when present (see setup.sh).
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
# Static values only; secrets/URLs come from .env via env_file.
|
||||
LANGFLOW_CONFIG_DIR: /app/langflow
|
||||
|
|
@ -68,12 +89,17 @@ services:
|
|||
# SQLite file inside the langflow-data volume (absolute path required
|
||||
# by Langflow); overrides any postgres URL that may linger in .env.
|
||||
LANGFLOW_DATABASE_URL: sqlite:////app/langflow/langflow.db
|
||||
# Custom form-input components (backend/components, category folders).
|
||||
LANGFLOW_COMPONENTS_PATH: /app/components
|
||||
# Seed the authoring UI with the canonical flows (read-only: the files
|
||||
# stay the source of truth, the API watcher is the only sync path).
|
||||
LANGFLOW_LOAD_FLOWS_PATH: /app/flows
|
||||
volumes:
|
||||
- langflow-data:/app/langflow
|
||||
- ./backend/components:/app/components:ro
|
||||
- ./backend/flows:/app/flows:ro
|
||||
ports:
|
||||
- "7860:7860"
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
|
|
@ -91,7 +117,17 @@ services:
|
|||
context: .
|
||||
dockerfile: Dockerfile.lfx
|
||||
env_file:
|
||||
- .env
|
||||
# Optional: fresh clones and CI checkouts have no `.env` (gitignored);
|
||||
# containers must still create so `config`/`build` work everywhere.
|
||||
# Runtime secrets come from `.env` when present (see setup.sh).
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
# lfx resolves its serve key via get_api_key(), which prefers
|
||||
# LANGFLOW_API_KEY over LFX_API_KEY. Our backend talks to LFX with
|
||||
# LFX_API_KEY, so blank the former here (empty counts as unset) —
|
||||
# otherwise every flow deploy 401s and /flows stays empty.
|
||||
LANGFLOW_API_KEY: ""
|
||||
volumes:
|
||||
- lfx-data:/flows
|
||||
ports:
|
||||
|
|
@ -110,6 +146,8 @@ services:
|
|||
|
||||
# Nightly auto-updates of :latest images (prod). Never started in dev
|
||||
# (not in devcontainer.json runServices; also needs a docker socket).
|
||||
# langflow/lfx carry no watchtower label on purpose: both are version-pinned
|
||||
# (major.minor must match) and update only via deliberate version bumps.
|
||||
watchtower:
|
||||
image: nickfedor/watchtower:latest
|
||||
volumes:
|
||||
|
|
@ -124,7 +162,5 @@ services:
|
|||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
kirche-venv:
|
||||
kirche-node-modules:
|
||||
langflow-data:
|
||||
lfx-data:
|
||||
|
|
|
|||
6
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Never bake host build output into the Linux image: the host checkout runs
|
||||
# on Windows, so a copied node_modules would shadow the image install with
|
||||
# wrong-platform binaries and diverge from package-lock.json.
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
|
@ -3,11 +3,21 @@
|
|||
# file. DevDeps are included: `npm run dev` must work with zero steps.
|
||||
FROM node:22-slim
|
||||
|
||||
# gosu drops privileges before serving (node user, uid 1000 — same as the
|
||||
# vscode editor user, so the bind-mounted node_modules stays writable).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /srv/kirche-einfach/frontend
|
||||
COPY package.json package-lock.json entrypoint.sh ./
|
||||
RUN npm ci && chmod +x entrypoint.sh
|
||||
RUN npm ci
|
||||
COPY . ./
|
||||
RUN chmod +x entrypoint.sh && chown -R node:node /srv/kirche-einfach/frontend
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
# Explicit `sh`: the repo bind-mount shadows the image at runtime, and git
|
||||
# tracks entrypoint.sh as 100644 — relying on the exec bit breaks Windows
|
||||
# checkouts (and fresh Linux clones). `sh script` needs read-only.
|
||||
ENTRYPOINT ["sh", "./entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
#!/bin/sh
|
||||
# Frontend entrypoint (dev + prod).
|
||||
# FRONTEND_MODE=dev (compose default) runs the HMR dev server.
|
||||
# FRONTEND_MODE=preview (prod .env) builds once and serves the bundle.
|
||||
# Fail closed: unset means the production preview server; dev sets
|
||||
# FRONTEND_MODE=dev in .env (see .devcontainer/setup.sh).
|
||||
set -e
|
||||
|
||||
cd /srv/kirche-einfach/frontend
|
||||
|
||||
if [ "${FRONTEND_MODE:-dev}" = "dev" ]; then
|
||||
exec npm run dev -- --host 0.0.0.0 --port 3000
|
||||
else
|
||||
npm run build
|
||||
exec npm run preview -- --host 0.0.0.0 --port 3000
|
||||
# node_modules lives in the repo bind-mount (no named volume by design):
|
||||
# install on first start after a fresh checkout (dev and prod).
|
||||
if [ ! -x "node_modules/.bin/vite" ]; then
|
||||
gosu node npm ci
|
||||
fi
|
||||
|
||||
if [ "${FRONTEND_MODE:-preview}" = "dev" ]; then
|
||||
exec gosu node npm run dev -- --host 0.0.0.0 --port 3000
|
||||
else
|
||||
# Build as node so dist/ stays editor-writable; preview serves as node.
|
||||
gosu node npm run build
|
||||
exec gosu node npm run preview -- --host 0.0.0.0 --port 3000
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -3,7 +3,30 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#1d4a79" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Kirche Einfach — KI-gestützte Verwaltungsplattform für Kirche"
|
||||
/>
|
||||
<title>Kirche Einfach</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon-32x32.png" type="image/png" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<script>
|
||||
try {
|
||||
var s = localStorage.getItem("kirche-color-scheme");
|
||||
if (
|
||||
s === "dark" ||
|
||||
(!s && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
) {
|
||||
document.documentElement.setAttribute(
|
||||
"data-mantine-color-scheme",
|
||||
"dark",
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
645
frontend/package-lock.json
generated
|
|
@ -8,13 +8,21 @@
|
|||
"name": "kirche-einfach-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.6.1",
|
||||
"@mantine/hooks": "^9.6.1",
|
||||
"@rjsf/core": "^6.10.0",
|
||||
"@rjsf/mantine": "^6.10.0",
|
||||
"@rjsf/validator-ajv8": "^6.10.0",
|
||||
"@tabler/icons-react": "^3.46.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",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"playwright": "^1.63.0",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
|
|
@ -743,6 +751,59 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.27.20",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
|
||||
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.9",
|
||||
"@floating-ui/utils": "^0.2.12",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.0",
|
||||
"react-dom": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
|
||||
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
|
@ -793,6 +854,50 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/core": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.6.1.tgz",
|
||||
"integrity": "sha512-UkcPo8Zq0uSdodvyXV62HVmfIfHCcrjFrFrH0TggTy6Wtp8a78znGP8RJOz8zC9XSPpJmKjBQjg0M+nOvIzftA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.20",
|
||||
"clsx": "^2.1.1",
|
||||
"react-number-format": "^5.4.5",
|
||||
"react-remove-scroll": "^2.7.2",
|
||||
"type-fest": "^5.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/hooks": "9.6.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "9.6.1",
|
||||
"@mantine/hooks": "9.6.1",
|
||||
"dayjs": ">=1.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mantine/hooks": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.1.tgz",
|
||||
"integrity": "sha512-pfxM95WozngB0ZALw6h99QV1TQ0qniOkFhzi0+dmSnKpxkVSR0xeNsvPpGDj9Teu+M69fRoE4IkzyMnu5Xld3w==",
|
||||
"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",
|
||||
|
|
@ -810,6 +915,78 @@
|
|||
"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/mantine": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/mantine/-/mantine-6.10.0.tgz",
|
||||
"integrity": "sha512-tqD2v7rCEaI5Dme66MiaIMem/zTuKJBn9+B1d5J8cVCOh77FmEyn1neVvB6rPK+jXNVY6jJD8DRW8vAzK6OPRQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": ">=8",
|
||||
"@mantine/dates": ">=8",
|
||||
"@mantine/hooks": ">=8",
|
||||
"@rjsf/core": "^6.10.0",
|
||||
"@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",
|
||||
|
|
@ -1167,6 +1344,32 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
|
||||
"integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons-react": {
|
||||
"version": "3.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz",
|
||||
"integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tabler/icons": "3.46.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
@ -1219,11 +1422,18 @@
|
|||
"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",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -1260,6 +1470,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",
|
||||
|
|
@ -1328,6 +1597,15 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
|
|
@ -1335,11 +1613,30 @@
|
|||
"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",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.23",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
|
||||
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
|
|
@ -1360,6 +1657,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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/electron-to-chromium": {
|
||||
"version": "1.5.420",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
|
||||
|
|
@ -1419,6 +1722,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",
|
||||
|
|
@ -1462,6 +1798,15 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-nonce": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
|
||||
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -1482,6 +1827,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 +1846,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 +1866,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",
|
||||
|
|
@ -1561,6 +1950,35 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
|
@ -1611,6 +2029,23 @@
|
|||
"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-number-format": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz",
|
||||
"integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
|
@ -1621,6 +2056,122 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-remove-scroll-bar": "^2.3.7",
|
||||
"react-style-singleton": "^2.2.3",
|
||||
"tslib": "^2.1.0",
|
||||
"use-callback-ref": "^1.3.3",
|
||||
"use-sidecar": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
|
||||
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-style-singleton": "^2.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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/react-style-singleton": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
|
||||
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-nonce": "^1.0.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 +2234,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",
|
||||
|
|
@ -1693,6 +2250,24 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
|
||||
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
|
@ -1710,6 +2285,27 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz",
|
||||
"integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
|
||||
|
|
@ -1755,6 +2351,49 @@
|
|||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sidecar": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
|
||||
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-node-es": "^1.1.0",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
|
|
|
|||
|
|
@ -6,16 +6,26 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"screenshots": "node scripts/capture.mjs",
|
||||
"icons": "node scripts/icons.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.6.1",
|
||||
"@mantine/hooks": "^9.6.1",
|
||||
"@rjsf/core": "^6.10.0",
|
||||
"@rjsf/mantine": "^6.10.0",
|
||||
"@rjsf/validator-ajv8": "^6.10.0",
|
||||
"@tabler/icons-react": "^3.46.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",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"playwright": "^1.63.0",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
|
|
|
|||
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 679 B |
15
frontend/public/favicon.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<style>
|
||||
.bg { fill: #1d4a79; }
|
||||
.fg { fill: #ffffff; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #356fae; }
|
||||
}
|
||||
</style>
|
||||
<rect class="bg" x="4" y="4" width="56" height="56" rx="14"/>
|
||||
<!-- stylized bell tower: spire + shaft + bell arch -->
|
||||
<path class="fg" d="M32 9 L41 25 H23 Z"/>
|
||||
<rect class="fg" x="25" y="25" width="14" height="27"/>
|
||||
<!-- bell arch knockout -->
|
||||
<path class="bg" d="M28.5 40 v-4 a3.5 3.5 0 0 1 7 0 v4 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 536 B |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
20
frontend/public/photos/SOURCES.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Landing-page photo sources
|
||||
|
||||
All files in this directory are self-hosted (no hotlinking, no tracking).
|
||||
Licensed for free use including commercial purposes, no attribution
|
||||
required (Pexels License). Re-fetch any time:
|
||||
|
||||
docker compose exec api /opt/venv/bin/python \
|
||||
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
|
||||
|
||||
Rejected during visual review (do NOT re-add without checking):
|
||||
US-megachurch conference screens, dark baroque interior, pandemic masks,
|
||||
Dutch-language documents.
|
||||
|
||||
| File | Motif | Source |
|
||||
| ---------------------- | ------------------------------ | ------ |
|
||||
| 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/ |
|
||||
BIN
frontend/public/photos/church-pews.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
frontend/public/photos/hero-church.webp
Normal file
|
After Width: | Height: | Size: 309 KiB |
BIN
frontend/public/photos/paperwork-desk.webp
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
frontend/public/photos/pilot-meeting.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/public/photos/team-laptop.webp
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
frontend/public/screenshots/hero-dark.png
Normal file
|
After Width: | Height: | Size: 894 KiB |
BIN
frontend/public/screenshots/hero-light.png
Normal file
|
After Width: | Height: | Size: 957 KiB |
BIN
frontend/public/screenshots/workflows-dark.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/public/screenshots/workflows-light.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
22
frontend/public/site.webmanifest
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "Kirche Einfach",
|
||||
"short_name": "Kirche",
|
||||
"description": "KI-gestützte Verwaltungsplattform für Kirche",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#1d4a79",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
66
frontend/scripts/capture.mjs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/* Repeatable landing-page screenshots (re-run after UI changes).
|
||||
*
|
||||
* Usage (frontend container, dev server running):
|
||||
* npm run screenshots # light scheme (default)
|
||||
* npm run screenshots -- --scheme=dark
|
||||
*
|
||||
* Captures real app states into public/screenshots/ (committed, so the
|
||||
* landing page always shows current UI). Authenticated states are out of
|
||||
* scope (magic-link login is not scriptable without SMTP).
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const outDir = join(root, "public", "screenshots");
|
||||
const base = process.env.SCREENSHOT_BASE_URL ?? "http://localhost:3000";
|
||||
const schemeArg = process.argv.find((a) => a.startsWith("--scheme="));
|
||||
const scheme = schemeArg ? schemeArg.split("=")[1] : "light";
|
||||
|
||||
const shots = [
|
||||
{ name: "hero", path: "/", fullPage: true },
|
||||
{
|
||||
name: "workflows",
|
||||
path: "/app/workflows",
|
||||
fullPage: false,
|
||||
selectFlow: "intake_form",
|
||||
},
|
||||
];
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
colorScheme: scheme === "dark" ? "dark" : "light",
|
||||
});
|
||||
await context.addInitScript(
|
||||
(value) => {
|
||||
try {
|
||||
localStorage.setItem("kirche-color-scheme", value);
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
},
|
||||
scheme === "dark" ? "dark" : "light",
|
||||
);
|
||||
const page = await context.newPage();
|
||||
for (const shot of shots) {
|
||||
await page.goto(base + shot.path, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1500);
|
||||
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);
|
||||
}
|
||||
const file = join(outDir, `${shot.name}-${scheme}.png`);
|
||||
await page.screenshot({ path: file, fullPage: shot.fullPage });
|
||||
console.log(`wrote ${file}`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
82
frontend/scripts/fetch-photos.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Fetch landing-page photos (one-time + re-runnable).
|
||||
|
||||
Downloads royalty-free originals (see SOURCES.md next to the photos),
|
||||
resizes to max 1600px wide and stores them as WebP q80 in
|
||||
frontend/public/photos/ (self-hosted: no hotlinking, no tracking).
|
||||
|
||||
Run in the api container (has network + Pillow):
|
||||
docker compose exec api /opt/venv/bin/python \\
|
||||
/srv/kirche-einfach/frontend/scripts/fetch-photos.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import urllib.request
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
sys.exit("Pillow required (present in the api image)")
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
OUT = ROOT / "frontend" / "public" / "photos"
|
||||
|
||||
|
||||
def pexels(photo_id: int) -> str:
|
||||
return (
|
||||
f"https://images.pexels.com/photos/{photo_id}/"
|
||||
f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600"
|
||||
)
|
||||
|
||||
|
||||
PHOTOS = {
|
||||
# target file -> (direct file URL, source page for SOURCES.md)
|
||||
"hero-church.webp": (
|
||||
pexels(31630127),
|
||||
"https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/",
|
||||
),
|
||||
"church-pews.webp": (
|
||||
pexels(7566207),
|
||||
"https://www.pexels.com/photo/church-pews-7566207/",
|
||||
),
|
||||
"paperwork-desk.webp": (
|
||||
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/",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def fetch(url: str) -> bytes:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=120) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for filename, (url, _page) in PHOTOS.items():
|
||||
raw = fetch(url)
|
||||
with Image.open(BytesIO(raw)) as img:
|
||||
img = img.convert("RGB")
|
||||
if img.width > 1600:
|
||||
img = img.resize(
|
||||
(1600, round(img.height * 1600 / img.width)), Image.LANCZOS
|
||||
)
|
||||
target = OUT / Path(filename).with_suffix(".webp").name
|
||||
img.save(target, "WEBP", quality=80, method=6)
|
||||
print(f"wrote {target} ({target.stat().st_size // 1024} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
44
frontend/scripts/icons.mjs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/* Generate favicon PNGs + manifest icons from favicon.svg (re-runnable).
|
||||
*
|
||||
* Usage (frontend container, Chromium already installed for screenshots):
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
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 targets = [
|
||||
["favicon-32x32.png", 32],
|
||||
["apple-touch-icon.png", 180],
|
||||
["icon-192.png", 192],
|
||||
["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();
|
||||
}
|
||||
|
|
@ -1,39 +1,161 @@
|
|||
import { useState } from "react";
|
||||
import AdminTab from "./components/AdminTab";
|
||||
import ApprovalsTab from "./components/ApprovalsTab";
|
||||
import FlowsTab from "./components/FlowsTab";
|
||||
import { Link, Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Anchor,
|
||||
AppShell,
|
||||
Button,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthProvider, useAuth } from "./auth";
|
||||
import SchemeToggle from "./components/SchemeToggle";
|
||||
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";
|
||||
function Nav() {
|
||||
const { me, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tabStyle = (active: boolean): React.CSSProperties => ({
|
||||
fontWeight: active ? "bold" : "normal",
|
||||
textDecoration: active ? "underline" : "none",
|
||||
});
|
||||
|
||||
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>
|
||||
{tab === "flows" && <FlowsTab />}
|
||||
{tab === "approvals" && <ApprovalsTab />}
|
||||
{tab === "admin" && <AdminTab />}
|
||||
</div>
|
||||
<Group gap="md">
|
||||
<Anchor component={Link} to="/">
|
||||
Start
|
||||
</Anchor>
|
||||
{!me && (
|
||||
<Anchor component={Link} to="/access">
|
||||
Zugang
|
||||
</Anchor>
|
||||
)}
|
||||
{me && (
|
||||
<>
|
||||
<Anchor component={Link} to="/app/workflows">
|
||||
Workflows
|
||||
</Anchor>
|
||||
<Anchor component={Link} to="/app/tasks">
|
||||
Aufgaben
|
||||
</Anchor>
|
||||
<Anchor component={Link} to="/app/admin">
|
||||
Verwaltung
|
||||
</Anchor>
|
||||
</>
|
||||
)}
|
||||
<SchemeToggle />
|
||||
{me && (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{me.email}
|
||||
</Text>
|
||||
<Button variant="subtle" size="compact-md" onClick={() => void onLogout()}>
|
||||
Abmelden
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { me, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<Center mt="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
// 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 />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const { loading } = useAuth();
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 64 }} padding="md">
|
||||
<AppShell.Header>
|
||||
<Container
|
||||
h="100%"
|
||||
style={{ display: "flex", alignItems: "center", gap: 16 }}
|
||||
>
|
||||
<Anchor
|
||||
component={Link}
|
||||
to="/"
|
||||
underline="never"
|
||||
style={{ color: "inherit" }}
|
||||
>
|
||||
<Title order={3}>Kirche Einfach</Title>
|
||||
</Anchor>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Nav />
|
||||
</div>
|
||||
</Container>
|
||||
</AppShell.Header>
|
||||
<AppShell.Main>
|
||||
<Container>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
KI-gestützte Verwaltungsplattform für Kirche
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Center mt="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<Routes>
|
||||
<Route path="/" element={<StartPage />} />
|
||||
<Route path="/access" element={<AccessPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/app/workflows"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<WorkflowsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/tasks"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<TasksPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app/admin"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<StartPage />} />
|
||||
</Routes>
|
||||
)}
|
||||
</Container>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Shell />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,22 +143,21 @@ 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: (
|
||||
taskId: string,
|
||||
decision: "approve" | "reject",
|
||||
approver: string,
|
||||
note: string,
|
||||
) =>
|
||||
decide: (taskId: string, decision: "approve" | "reject", note: string) =>
|
||||
req<Decision>(`/api/approvals/${taskId}/decision`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision, approver, note }),
|
||||
// Identity comes from the session; the server records the user.
|
||||
body: JSON.stringify({ decision, note }),
|
||||
}),
|
||||
importFlow: (langflowFlowId: string, name?: string) =>
|
||||
req<{ definition: FlowDefinition; version: FlowVersion }>(
|
||||
|
|
@ -148,4 +176,38 @@ 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 }),
|
||||
}),
|
||||
requestMagicLink: (email: string) =>
|
||||
req<{ message: string; email_sent: boolean }>("/api/auth/magic/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
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
|
|
@ -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);
|
||||
}
|
||||
130
frontend/src/components/AccessQueue.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useCallback, 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 StatusBadge from "./StatusBadge";
|
||||
|
||||
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 — die E-Mail kam nicht an (SMTP prüfen).");
|
||||
}
|
||||
} 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>
|
||||
<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>
|
||||
)}
|
||||
{pending.length === 0 && (
|
||||
<Text c="dimmed" mb="md">
|
||||
Keine offenen Anfragen.
|
||||
</Text>
|
||||
)}
|
||||
<Stack mb="md">
|
||||
{pending.map((r) => (
|
||||
<Card withBorder padding="md" key={r.id}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<StatusBadge status={r.status} />
|
||||
<strong>{r.name}</strong>
|
||||
<Text size="sm" c="dimmed">
|
||||
{r.email}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.reason && (
|
||||
<Text size="sm" mb="sm">
|
||||
{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>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
{done.length > 0 && (
|
||||
<>
|
||||
<Title order={4} mb="xs">
|
||||
Entschieden ({done.length})
|
||||
</Title>
|
||||
{done.map((r) => (
|
||||
<Group key={r.id} gap="xs" mb={4}>
|
||||
<StatusBadge status={r.status} />
|
||||
<Text size="sm">
|
||||
{r.name} · {r.email}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,22 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconExternalLink,
|
||||
IconRefresh,
|
||||
IconUpload,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ApiError,
|
||||
LANGFLOW_URL,
|
||||
|
|
@ -6,6 +24,7 @@ import {
|
|||
type FlowDefinition,
|
||||
type FlowVersion,
|
||||
} from "../api";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
export default function AdminTab() {
|
||||
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
||||
|
|
@ -86,61 +105,110 @@ export default function AdminTab() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2>Administration</h2>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
{info && <p style={{ color: "green" }}>{info}</p>}
|
||||
<p>
|
||||
Flows werden visuell in Langflow entworfen:{" "}
|
||||
<a href={LANGFLOW_URL} target="_blank" rel="noreferrer">
|
||||
Langflow öffnen
|
||||
</a>
|
||||
</p>
|
||||
<h3>Import aus Langflow</h3>
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<input
|
||||
value={langflowId}
|
||||
onChange={(e) => setLangflowId(e.target.value)}
|
||||
placeholder="Langflow Flow-ID (UUID)"
|
||||
style={{ flex: 1, fontFamily: "monospace" }}
|
||||
/>
|
||||
<input
|
||||
value={importName}
|
||||
onChange={(e) => setImportName(e.target.value)}
|
||||
placeholder="Name (optional)"
|
||||
/>
|
||||
<button onClick={() => void onImport()} disabled={!langflowId.trim()}>
|
||||
Importieren + Deployen
|
||||
</button>
|
||||
</div>
|
||||
<h3>Flows + Versionen</h3>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button onClick={() => void onSync()}>
|
||||
Aus backend/flows synchronisieren
|
||||
</button>
|
||||
</div>
|
||||
{flows.length === 0 && <p>Keine Flows vorhanden.</p>}
|
||||
<ul>
|
||||
{flows.map((f) => (
|
||||
<li key={f.id} style={{ marginBottom: 8 }}>
|
||||
<strong>{f.name}</strong>{" "}
|
||||
<span style={{ fontFamily: "monospace", fontSize: "0.85em" }}>
|
||||
{f.source_path ? `(${f.source_path})` : "(API)"}
|
||||
</span>
|
||||
<ul>
|
||||
{(versions[f.id] ?? []).map((v) => (
|
||||
<li key={v.id}>
|
||||
v{v.version_no} ·{" "}
|
||||
{v.deployed_at ? `deployed (${v.lfx_flow_id})` : "nicht deployed"} ·{" "}
|
||||
{v.author || "–"}{" "}
|
||||
<button onClick={() => void onDeploy(f.id, v.version_no)}>
|
||||
Deployen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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>
|
||||
)}
|
||||
<Card withBorder padding="md" mb="md">
|
||||
<Text mb="xs">
|
||||
Flows werden visuell in Langflow entworfen:{" "}
|
||||
<Anchor href={LANGFLOW_URL} target="_blank" rel="noreferrer">
|
||||
Langflow öffnen <IconExternalLink size={14} />
|
||||
</Anchor>
|
||||
</Text>
|
||||
<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" } }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name (optional)"
|
||||
value={importName}
|
||||
onChange={(e) => setImportName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<IconUpload size={16} />}
|
||||
onClick={() => void onImport()}
|
||||
disabled={!langflowId.trim()}
|
||||
>
|
||||
Importieren + Deployen
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="md" mb="md">
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => void onSync()}
|
||||
>
|
||||
Aus backend/flows synchronisieren
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
{flows.length === 0 ? (
|
||||
<Text c="dimmed">Keine Flows vorhanden.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Flow</Table.Th>
|
||||
<Table.Th>Version</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Autor</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{flows.map((f) =>
|
||||
(versions[f.id] ?? []).map((v, i) => (
|
||||
<Table.Tr key={v.id}>
|
||||
<Table.Td>
|
||||
{i === 0 && (
|
||||
<>
|
||||
<strong>{f.name}</strong>{" "}
|
||||
<Text size="xs" c="dimmed" component="span">
|
||||
{f.source_path ? `(${f.source_path})` : "(API)"}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>v{v.version_no}</Table.Td>
|
||||
<Table.Td>
|
||||
{v.deployed_at ? (
|
||||
<StatusBadge status="deployed" />
|
||||
) : (
|
||||
<StatusBadge status="pending" />
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{v.author || "–"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
onClick={() => void onDeploy(f.id, v.version_no)}
|
||||
>
|
||||
Deployen
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)),
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
import { useCallback, 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 { useAuth } from "../auth";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
export default function ApprovalsTab() {
|
||||
const { me } = useAuth();
|
||||
const [tasks, setTasks] = useState<ApprovalTask[]>([]);
|
||||
const [approver, setApprover] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busyId, setBusyId] = useState("");
|
||||
|
|
@ -24,7 +38,7 @@ export default function ApprovalsTab() {
|
|||
setBusyId(taskId);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.decide(taskId, decision, approver, note);
|
||||
const result = await api.decide(taskId, decision, note);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? result.task : t)),
|
||||
);
|
||||
|
|
@ -40,56 +54,88 @@ export default function ApprovalsTab() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<h2>Freigaben</h2>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||
<input
|
||||
value={approver}
|
||||
onChange={(e) => setApprover(e.target.value)}
|
||||
placeholder="Name (Bearbeiter)"
|
||||
/>
|
||||
<input
|
||||
<Title order={3} mb="xs">
|
||||
Freigaben
|
||||
</Title>
|
||||
{error && (
|
||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Angemeldet als {me?.email ?? "–"} — Entscheidungen werden diesem
|
||||
Konto zugeordnet.
|
||||
</Text>
|
||||
<Group mb="md" align="flex-end">
|
||||
<TextInput
|
||||
label="Notiz"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Notiz"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button onClick={() => void load()}>Aktualisieren</button>
|
||||
</div>
|
||||
<h3>Offen ({open.length})</h3>
|
||||
{open.length === 0 && <p>Keine offenen Freigaben.</p>}
|
||||
<ul>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</Group>
|
||||
<Title order={4} mb="xs">
|
||||
Offen ({open.length})
|
||||
</Title>
|
||||
{open.length === 0 && (
|
||||
<Text c="dimmed" mb="md">
|
||||
Keine offenen Freigaben.
|
||||
</Text>
|
||||
)}
|
||||
<Stack mb="md">
|
||||
{open.map((t) => (
|
||||
<li key={t.id} style={{ marginBottom: 8 }}>
|
||||
<strong>{t.title || t.id}</strong> · {t.tenant_id}
|
||||
<br />
|
||||
<code>{JSON.stringify(t.payload)}</code>
|
||||
<br />
|
||||
<button
|
||||
onClick={() => void decide(t.id, "approve")}
|
||||
disabled={busyId === t.id}
|
||||
>
|
||||
Freigeben
|
||||
</button>{" "}
|
||||
<button
|
||||
onClick={() => void decide(t.id, "reject")}
|
||||
disabled={busyId === t.id}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</li>
|
||||
<Card withBorder padding="md" key={t.id}>
|
||||
<Group gap="xs" mb="xs">
|
||||
<StatusBadge status={t.status} />
|
||||
<strong>{t.title || t.id}</strong>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t.tenant_id}
|
||||
</Text>
|
||||
</Group>
|
||||
<Code block mb="sm">
|
||||
{JSON.stringify(t.payload)}
|
||||
</Code>
|
||||
<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>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</ul>
|
||||
<h3>Entschieden ({done.length})</h3>
|
||||
<ul>
|
||||
{done.map((t) => (
|
||||
<li key={t.id}>
|
||||
<strong>{t.status}</strong> · {t.title || t.id} ·{" "}
|
||||
{t.approver ?? "–"}
|
||||
</Stack>
|
||||
<Title order={4} mb="xs">
|
||||
Entschieden ({done.length})
|
||||
</Title>
|
||||
{done.map((t) => (
|
||||
<Group key={t.id} gap="xs" mb={4}>
|
||||
<StatusBadge status={t.status} />
|
||||
<Text size="sm">
|
||||
{t.title || t.id} · {t.approver ?? "–"}
|
||||
{t.decision_note ? ` · „${t.decision_note}“` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ApiError, api, type FlowDefinition, type Run } from "../api";
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
export default function FlowsTab() {
|
||||
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
||||
const [flowId, setFlowId] = useState("");
|
||||
const [inputJson, setInputJson] = useState(
|
||||
'{\n "betrag": 120000,\n "schwelle": 200000\n}',
|
||||
);
|
||||
const [tenant, setTenant] = useState("default");
|
||||
const [runs, setRuns] = useState<Run[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadFlows = useCallback(async () => {
|
||||
try {
|
||||
const list = await api.listFlows();
|
||||
setFlows(list);
|
||||
const first = list[0];
|
||||
if (first && !flowId) setFlowId(first.id);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
||||
}
|
||||
}, [flowId]);
|
||||
|
||||
const loadRuns = useCallback(async (id: string) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setRuns(await api.listRuns(id));
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFlows();
|
||||
}, [loadFlows]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRuns(flowId);
|
||||
}, [flowId, loadRuns]);
|
||||
|
||||
async function onRun() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const payload: unknown = JSON.parse(inputJson);
|
||||
const run = await api.runFlow(flowId, payload, tenant);
|
||||
setRuns((prev) => [run, ...prev]);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) setError("Ungültiges JSON in der Eingabe.");
|
||||
else setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Vorgang starten</h2>
|
||||
{error && <p style={{ color: "crimson" }}>{error}</p>}
|
||||
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
|
||||
<select value={flowId} onChange={(e) => setFlowId(e.target.value)}>
|
||||
<option value="">— Flow wählen —</option>
|
||||
{flows.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={tenant}
|
||||
onChange={(e) => setTenant(e.target.value)}
|
||||
placeholder="Mandant"
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<button onClick={() => void onRun()} disabled={!flowId || busy}>
|
||||
{busy ? "Läuft …" : "Starten"}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
rows={6}
|
||||
style={inputStyle}
|
||||
value={inputJson}
|
||||
onChange={(e) => setInputJson(e.target.value)}
|
||||
/>
|
||||
<h3>Letzte Läufe</h3>
|
||||
{runs.length === 0 && <p>Noch keine Läufe.</p>}
|
||||
<ul>
|
||||
{runs.map((r) => (
|
||||
<li key={r.id}>
|
||||
<strong>{r.status}</strong> · {r.tenant_id} ·{" "}
|
||||
<code>{r.output?.result ?? r.output?.error ?? "–"}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/SchemeToggle.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { ActionIcon, useMantineColorScheme } from "@mantine/core";
|
||||
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||
|
||||
export default function SchemeToggle() {
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === "dark";
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={() => toggleColorScheme()}
|
||||
aria-label={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
|
||||
title={dark ? "Zum hellen Modus" : "Zum dunklen Modus"}
|
||||
>
|
||||
{dark ? <IconSun size={20} /> : <IconMoon size={20} />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Badge } from "@mantine/core";
|
||||
|
||||
/* Single color language for every status in the app. */
|
||||
|
||||
const COLORS: Record<string, string> = {
|
||||
completed: "green",
|
||||
approved: "green",
|
||||
genehmigt: "green",
|
||||
deployed: "green",
|
||||
running: "blue",
|
||||
open: "yellow",
|
||||
pending: "yellow",
|
||||
pending_approval: "yellow",
|
||||
failed: "red",
|
||||
rejected: "red",
|
||||
abgelehnt: "red",
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge color={COLORS[status] ?? "gray"}>{status}</Badge>;
|
||||
}
|
||||
|
|
@ -1,9 +1,22 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import "@mantine/core/styles.css";
|
||||
import "./photos.css";
|
||||
import App from "./App";
|
||||
import { colorSchemeManager, theme } from "./theme";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<MantineProvider
|
||||
theme={theme}
|
||||
colorSchemeManager={colorSchemeManager}
|
||||
defaultColorScheme="light"
|
||||
>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</MantineProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||