From 1e8050611c177a8cc9b003b3a6c55405a3441680 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Wed, 9 Sep 2026 09:44:59 +0000 Subject: [PATCH 01/24] chore: phase 0 reproducible docker/dev setup with version pinning --- .devcontainer/devcontainer.json | 6 +- .devcontainer/setup.sh | 33 +++++-- .env.example | 26 +++++- .gitignore | 4 + Dockerfile.lfx | 5 +- PLAN_NEXT.md | 158 ++++++++++++++++++++++++++++++++ README.md | 18 +++- backend/Dockerfile | 16 +++- backend/entrypoint.sh | 14 ++- backend/pyproject.toml | 4 +- docker-compose.yml | 27 ++++-- frontend/entrypoint.sh | 6 ++ 12 files changed, 286 insertions(+), 31 deletions(-) create mode 100644 PLAN_NEXT.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 41ed32c..c97848c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,7 +11,9 @@ }, "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=/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": {} @@ -29,7 +31,7 @@ "esbenp.prettier-vscode" ], "settings": { - "python.defaultInterpreterPath": "/srv/kirche-einfach/backend/.venv/bin/python" + "python.defaultInterpreterPath": "/opt/venv/bin/python" } } } diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index d002adf..a92a38d 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -6,12 +6,34 @@ set -e REPO=/srv/kirche-einfach +# 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 +su vscode -c "uv sync" # opencode CLI (dev tooling; intentionally not baked into the image). if [ ! -x /home/vscode/.opencode/bin/opencode ]; then @@ -57,13 +79,8 @@ 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)" +su vscode -c "uv run python scripts/sync_flows.py --sync" || echo "WARN: flow sync failed (check backend/flows and LFX)" 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 diff --git a/.env.example b/.env.example index e65f05c..58f66f3 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,15 @@ # 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. +# 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 LANGFLOW_SUPERUSER_PASSWORD= @@ -23,5 +28,18 @@ CALLBACK_BASE_URL=http://api:8000 # --- frontend mode: dev server with HMR (prod .env must set `preview`) --- 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= \ No newline at end of file diff --git a/.gitignore b/.gitignore index ba92757..2e99234 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ backend/data/ *.swp *.swo +# Devcontainer +.devcontainer/devcontainer-lock.json +.devcontainer/opencode-data/ + # OS .DS_Store Thumbs.db diff --git a/Dockerfile.lfx b/Dockerfile.lfx index 00514b0..378b23d 100644 --- a/Dockerfile.lfx +++ b/Dockerfile.lfx @@ -8,7 +8,10 @@ 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 VOLUME /flows diff --git a/PLAN_NEXT.md b/PLAN_NEXT.md new file mode 100644 index 0000000..b0e1afc --- /dev/null +++ b/PLAN_NEXT.md @@ -0,0 +1,158 @@ +# Continuation plan — kirche-einfach next steps (written after Phase 0) + +> For a future agent restoring this session (e.g. after a container rebuild). +> Read this file + `AGENTS.md` + `README.md` first. All code/comments in +> English; only user-visible website strings are German. + +## 0. Where we are + +**Phase 0 (this commit) is done** — Docker/devcontainer reproducibility: + +* `langflow` image pinned via `${LANGFLOW_VERSION:-1.12.0}` (was `:latest` + with nightly Watchtower drift). `Dockerfile.lfx` pins `lfx~=` + (`ARG LFX_VERSION=1.12.0`). Backend test dep pinned `lfx~=1.12.0`. + Reason: official LFX contract — `major.minor` of Langflow and LFX must + match, patches are independent. +* `langflow` mounts `backend/components` (`LANGFLOW_COMPONENTS_PATH`) and + `backend/flows` (`LANGFLOW_LOAD_FLOWS_PATH`), both `:ro`. Files stay + source of truth; the API watcher (`app/flows/sync.py`) remains the ONLY + sync path — never add a second watcher. +* Backend venv moved to `/opt/venv` (image-local, `UV_PROJECT_ENVIRONMENT`), + managed Python to `/opt/python` (`UV_PYTHON_INSTALL_DIR`), both `a+rX`. + Removed `kirche-venv` + `kirche-node-modules` named volumes (they shadowed + image content with empty volumes on first start = split-brain). + `backend/entrypoint.sh` uses `/opt/venv/bin/*`, ensures `data/` exists and + is `vscode`-owned. `frontend/entrypoint.sh` runs `npm ci` when + `node_modules/.bin/vite` is missing (covers fresh prod checkouts). +* `devcontainer.json`: opencode session persistence via named volumes + `kirche-opencode-share → ~/.local/share/opencode` (sessions DB) and + `kirche-opencode-config → ~/.config/opencode`; interpreter path is now + `/opt/venv/bin/python`. `setup.sh` chowns first, then runs `uv sync` as + `vscode` (was: root `uv sync` into `/root`-backed venv → `Permission + denied` for vscode; `backend/data` root-owned → DB unwritable). +* Stale local `backend/.venv/` (bind-mount leftover) can be deleted manually + after rebuild: `rm -rf backend/.venv`. It is gitignored; nothing references + it anymore. + +**Verified in this session:** `uv run pytest` green (after local venv repair, +see §5). NOT verifiable here (no node/docker in this sandbox): `npm run +build`, `docker compose config`, container rebuild. CI covers all three +(`.forgejo/workflows/`). After rebuilding the devcontainer, re-run: `uv run +pytest` (attached api, from `backend/`), `docker compose exec frontend npm +run build`, `docker compose config --quiet`. + +**Still open (docs-only):** `README.md` + `.env.example` uncommitted diff +describes the target vision (access mgmt, dynamic forms, new Web UI) that is +NOT yet implemented: `app/auth/` is empty, `backend/components/kirche/` is +empty, frontend is still the old 3-tab UI, no `input-schema` endpoint. + +## 1. Locked decisions (do not re-ask) + +* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`, + store `sha256`, `used_at` set before session creation), session = opaque id + in **DB table**, cookie `HttpOnly; Secure; SameSite=Lax; Path=/`. + SMTP creds already in `.env` (`SMTP_HOST/PORT/USERNAME/PASSWORD`, + `MAIL_FROM`, `CONTACT_TO`); dev fallback logs the link when SMTP unset. +* `FileInput` is a **stub** for now (string reference only); real upload later. +* Frontend: `react-router-dom` + RJSF (`@rjsf/core` + `@rjsf/validator-ajv8`). + RJSF renders backend-supplied `{json_schema, ui_schema}`; server + re-validates (client validation is UX only). +* **Routes are pure English** (user correction — no German slugs anywhere, + including URLs): + `/`, `/access` (was once sketched as `/zugang` — do NOT use), + `/login?token=`, `/app/workflows`, `/app/tasks`, `/app/admin`. + Visible labels/nav are German (`Start`, `Zugang`, `Workflows`, `Aufgaben`, + `Verwaltung`); code, props, comments, routes English. +* Langflow preload + mount (done in Phase 0). Custom-component lib in + `backend/components/kirche/` **must stay GUI-editable**: category folder + + `__init__.py` + `Component` subclasses using `lfx.io` inputs only + (stdlib + `lfx`, so the LFX runtime can execute them). +* Watchtower: `langflow` label removed (pinned images must not auto-update). + Version bumps = deliberate `LANGFLOW_VERSION` + `LFX_VERSION` change, then + `lfx upgrade --upgrade-flow=safe` check over `backend/flows/*.json`. + +## 2. Phase 1 — Backend auth (magic-link + RBAC) + +Files: `backend/app/models/user.py` (`User`, `AccessRequest`, +`MagicLinkToken`, `Session`), `backend/app/auth/{schemas,service,mail,api, +deps}.py`, wire router in `app/main.py`, settings in `app/settings.py` +(`frontend_url, admin_emails, contact_to, mail_from, smtp_*`), ONE new +Alembic revision (never edit applied migrations). + +Endpoints: + +* `POST /api/access/requests` public, rate-limited: `{name,email,reason}` + → row `pending` + notify `CONTACT_TO`. +* `POST /api/access/requests/{id}/approve|reject` admin: approve → + upsert `User` + emit magic-link + SMTP send. +* `POST /api/auth/magic/consume {token}` → session cookie; `GET + /api/auth/me`; `POST /api/auth/logout` (delete session row). +* `POST /api/auth/admin-bootstrap`: only when user table empty AND email in + `ADMIN_EMAILS`. +* Tenant from session (`user.tenant_id`); keep old `tenant_id` body param as + deprecated fallback. `require_admin` dependency; keep API handlers thin + (DB only via `app/flows/store.py` + new `app/auth/*` service funcs). + +Tests (all in `backend/tests/`): request→approve→consume→me→logout happy +path; token expiry boundary (15 min); token reuse → 400; admin-only → 403; +bootstrap-once; cookie flags (`HttpOnly`, `SameSite`, `Path`); assert no +token/secret in logs. + +## 3. Phase 2 — `CustomInput*` library (GUI-editable) + +* `backend/components/kirche/__init__.py` + + `backend/components/kirche/form_inputs.py`: + `class CustomInputForm(Component)` with inputs `StrInput`, + `MultilineInput`, `SecretStrInput`, `IntInput`, `FloatInput`, `BoolInput`, + `DropdownInput`, `MultiselectInput`, `SliderInput`, `FileInput` (stub) + + `Output`. Category dir `kirche/` = appears in Langflow GUI. +* Regenerate/add fixture flow using it via + `backend/scripts/make_flow_fixtures.py`; keep `simple_rule.json` as the + textarea-fallback case. Commit flow JSON (source of truth). + +## 4. Phase 3 — Dynamic-form parsing (no second watcher) + +* New `backend/app/flows/input_schema.py`: + `extract_form_schema(langflow_json) -> {json_schema, ui_schema}`. + Find nodes with `type`/`display_name` starting `CustomInput`; iterate + `template` minus `code`; map `_input_type` → JSON Schema type/widget + (`StrInput`/`MessageTextInput`→string, `MultilineInput`→string+textarea, + `SecretStrInput`→string+password, `IntInput`→integer, + `FloatInput`→number, `BoolInput`→boolean, `DropdownInput`→string+enum, + `MultiselectInput`→array+enum, `SliderInput`→number+range, + `FileInput`→string stub); `display_name/info/value/options/required/ + advanced` → `title/description/default/enum/required/uiSchema`. + No such node → `{fallback: "textarea"}`. +* Called from existing `sync_from_directory` (cache per version in new + nullable `input_schema` JSON column via Alembic) + new `GET + /api/flows/{id}/input-schema → {flow_id, version_no, json_schema, + ui_schema}` (latest **deployed** version; 404 when none). +* Tests: per-type mapping table, fallback, sync persistence, endpoint 404. + +## 5. Phase 4 — Frontend (router + RJSF, German UI, English routes) + +Deps: `react-router-dom`, `@rjsf/core`, `@rjsf/validator-ajv8`. +Routes (English, see §1): `/`, `/access`, `/login`, `/app/workflows` +(flow select → `GET input-schema` → RJSF `
` → `POST run`), +`/app/tasks` (own runs + approval states), `/app/admin` (admin-only: +access-request queue, ALL tasks incl. open human-intervention with +approve/reject + note, import/sync/deploy). `fetch` with +`credentials: "include"`. Refactor `FlowsTab/ApprovalsTab/AdminTab` into +route components; drop old tab shell. Verify with `npm run build`. + +## 6. Operational notes for the next agent + +* Commands: backend from `backend/`: `uv run pytest`, + `uv run alembic upgrade head`, `uv run python scripts/sync_flows.py + --sync`; frontend via `docker compose exec frontend npm run build`. +* Local venv repair (this sandbox had a root-backed broken `.venv`): + `rm -rf backend/.venv && uv sync` (network needed for `uv python + install`). In rebuilt containers this is unnecessary (`/opt/venv`). +* `.env` (gitignored) still lacks `FRONTEND_URL`/`ADMIN_EMAILS` from the + `.env.example` diff — copy them over locally when starting Phase 1. Never + commit `.env`. +* Flow JSON that fails LFX after a version bump: run + `lfx upgrade --upgrade-flow=safe` on the file, review diff, commit. +* Definition of done per `AGENTS.md`: new behavior has tests, `uv run + pytest` green, `npm run build` green when frontend changed, schema changes + as new Alembic revisions. diff --git a/README.md b/README.md index 8ca5514..7061d29 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,22 @@ 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. +- **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). ## Infrastructure diff --git a/backend/Dockerfile b/backend/Dockerfile index 7c5cdbb..06d304e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,7 +4,14 @@ 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 @@ -14,7 +21,9 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends sudo openssh-client \ && 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,7 +32,8 @@ 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 diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 71b0868..f329967 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -6,10 +6,18 @@ 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 if [ "${APP_ENV:-prod}" = "dev" ]; then - exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + exec /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload else - exec .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 + exec /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 fi diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bfec02f..e001a6f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -27,7 +27,9 @@ 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", diff --git a/docker-compose.yml b/docker-compose.yml index cc0eec2..6a6313f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,11 @@ services: env_file: - .env 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: @@ -45,8 +48,10 @@ services: # Dev default (HMR); prod .env sets `preview` (build + serve bundle). FRONTEND_MODE: ${FRONTEND_MODE:-dev} 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,7 +60,10 @@ 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 environment: @@ -68,12 +76,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: @@ -110,6 +123,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 +139,5 @@ services: restart: unless-stopped volumes: - kirche-venv: - kirche-node-modules: langflow-data: lfx-data: diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh index 13d0ea0..7da02cf 100644 --- a/frontend/entrypoint.sh +++ b/frontend/entrypoint.sh @@ -6,6 +6,12 @@ set -e cd /srv/kirche-einfach/frontend +# 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 + npm ci +fi + if [ "${FRONTEND_MODE:-dev}" = "dev" ]; then exec npm run dev -- --host 0.0.0.0 --port 3000 else From c2125f063b1d6cdf91ea063008f8f5872db52a06 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Fri, 11 Sep 2026 17:18:59 +0200 Subject: [PATCH 02/24] fix: devcontainer build and setup on fresh clones --- .devcontainer/devcontainer.json | 8 ++++++-- .devcontainer/setup.sh | 27 ++++++++++++++++++++------- backend/.dockerignore | 9 +++++++++ backend/Dockerfile | 5 +++-- backend/entrypoint.sh | 6 ++++++ docker-compose.yml | 24 ++++++++++++++++++++---- frontend/.dockerignore | 6 ++++++ 7 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 frontend/.dockerignore diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c97848c..360eee8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,12 +11,16 @@ }, "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", diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index a92a38d..dda5724 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -2,10 +2,19 @@ # 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.) +as_vscode() { + sudo -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 @@ -33,22 +42,26 @@ chown -R vscode:vscode "$REPO/backend/data" \ # the editor; uv resolves at setup time, no lockfile is committed). echo "--- Installing Python dependencies ---" cd "$REPO/backend" -su vscode -c "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 +echo "--- Waiting for compose services ---" python3 - <<'EOF' import socket import sys @@ -79,7 +92,7 @@ EOF # entrypoint on every start). echo "--- Syncing canonical flows ---" cd "$REPO/backend" -su vscode -c "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 "" echo "=== Setup complete ===" diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..b519d04 --- /dev/null +++ b/backend/.dockerignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 06d304e..d31f76b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,9 +16,10 @@ 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. 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 \ && rm -rf /var/lib/apt/lists/* \ && useradd -m -s /bin/bash vscode \ && echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode \ diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index f329967..a47bf93 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -16,6 +16,12 @@ 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 /opt/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload else diff --git a/docker-compose.yml b/docker-compose.yml index 6a6313f..5ca297a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,11 @@ 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 @@ -43,7 +47,11 @@ 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} @@ -65,7 +73,11 @@ services: # .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 @@ -104,7 +116,11 @@ 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 volumes: - lfx-data:/flows ports: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..7ff60fe --- /dev/null +++ b/frontend/.dockerignore @@ -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 From c8280f7078cb4f30ab9ff9fa8107aeb08bad19d5 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Fri, 11 Sep 2026 17:27:58 +0200 Subject: [PATCH 03/24] feat: magic-link auth with access requests and RBAC --- backend/app/auth/__init__.py | 1 + backend/app/auth/api.py | 173 +++++++++++ backend/app/auth/deps.py | 57 ++++ backend/app/auth/mail.py | 66 +++++ backend/app/auth/schemas.py | 49 ++++ backend/app/auth/service.py | 277 ++++++++++++++++++ backend/app/flows/api.py | 17 ++ backend/app/main.py | 3 + backend/app/models/__init__.py | 12 +- backend/app/models/user.py | 72 +++++ backend/app/settings.py | 23 +- .../versions/a3f1c9e2b4d7_auth_tables.py | 82 ++++++ backend/tests/test_auth.py | 228 ++++++++++++++ 13 files changed, 1058 insertions(+), 2 deletions(-) create mode 100644 backend/app/auth/__init__.py create mode 100644 backend/app/auth/api.py create mode 100644 backend/app/auth/deps.py create mode 100644 backend/app/auth/mail.py create mode 100644 backend/app/auth/schemas.py create mode 100644 backend/app/auth/service.py create mode 100644 backend/app/models/user.py create mode 100644 backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py create mode 100644 backend/tests/test_auth.py diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..0466d57 --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1 @@ +"""Magic-link authentication + access-request approval (SMTP invite).""" diff --git a/backend/app/auth/api.py b/backend/app/auth/api.py new file mode 100644 index 0000000..153668b --- /dev/null +++ b/backend/app/auth/api.py @@ -0,0 +1,173 @@ +"""Access + auth HTTP API (magic-link login, session cookie).""" + +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, + 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: 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( + status: str | None = Query(default=None), + session: Session = Depends(get_db), + admin: User = Depends(require_admin), +): + 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: Session = Depends(get_db), + admin: 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: Session = Depends(get_db), + admin: 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/consume", response_model=MeRead) +def consume_magic( + data: MagicConsumeRequest, + response: Response, + session: 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: User = Depends(get_current_user)): + return _read_me(user) + + +@auth_router.post("/logout", response_model=MessageRead) +def logout( + request: Request, + response: Response, + session: 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: 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") diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py new file mode 100644 index 0000000..bdaa2ce --- /dev/null +++ b/backend/app/auth/deps.py @@ -0,0 +1,57 @@ +"""Session-cookie dependencies. Handlers stay thin; DB via get_session.""" + +from __future__ import annotations + +from collections.abc import Iterator + +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: 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: 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: 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 diff --git a/backend/app/auth/mail.py b/backend/app/auth/mail.py new file mode 100644 index 0000000..c8ca35b --- /dev/null +++ b/backend/app/auth/mail.py @@ -0,0 +1,66 @@ +"""Magic-link delivery. SMTP when configured, dev log fallback otherwise.""" + +from __future__ import annotations + +import logging +import smtplib +from email.message import EmailMessage + +from app.settings import settings + +logger = logging.getLogger(__name__) + + +def magic_link(token: str) -> str: + return f"{settings.frontend_url.rstrip('/')}/login?token={token}" + + +def send_magic_link(to_email: str, link: str) -> bool: + """Mail the login link. Returns False (logs instead) when SMTP is unset. + + 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: + logger.info("SMTP not configured; magic link for %s: %s", to_email, link) + return False + message = EmailMessage() + message["Subject"] = "Your kirche-einfach login link" + 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"Hello,\n\nUse this link to log in (valid 15 minutes, single use):\n{link}\n" + ) + 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) + 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 = EmailMessage() + message["Subject"] = f"New access request: {name}" + message["To"] = settings.contact_to + message["From"] = settings.mail_from or settings.contact_to + message.set_content( + f"Name: {name}\nEmail: {email}\nRequest: {request_id}\n" + ) + try: + 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) + except OSError: + logger.warning("access-request notification failed for %s", request_id) + return False + return True diff --git a/backend/app/auth/schemas.py b/backend/app/auth/schemas.py new file mode 100644 index 0000000..cee9e35 --- /dev/null +++ b/backend/app/auth/schemas.py @@ -0,0 +1,49 @@ +"""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 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 diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py new file mode 100644 index 0000000..bf13261 --- /dev/null +++ b/backend/app/auth/service.py @@ -0,0 +1,277 @@ +"""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 datetime, timedelta, timezone +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 + + +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(timezone.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 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=timezone.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 diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index 6d4aaa9..0a4dd0b 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -13,7 +13,9 @@ from fastapi.responses import StreamingResponse from sqlmodel import Session from app.db import get_engine, get_session +from app.models import User from app.settings import settings +from app.auth.deps import get_optional_user, resolve_tenant from . import runner, store from .deploy import import_from_langflow @@ -225,7 +227,11 @@ async def run_flow( stream: bool = Query(default=False), session: Session = Depends(get_db), lfx: LfxClient = Depends(get_lfx), + user: User | None = Depends(get_optional_user), ): + # Tenant comes from the session; the body param stays as a deprecated + # fallback for unauthenticated callers. + data = data.model_copy(update={"tenant_id": resolve_tenant(user, data.tenant_id)}) try: version = _resolve_version(session, flow_id, data.version_no) except store.NotFoundError as exc: @@ -316,7 +322,12 @@ def list_runs( flow_id: UUID, tenant_id: str | None = Query(default=None), session: Session = Depends(get_db), + user: User | None = Depends(get_optional_user), ): + # Authenticated callers are scoped to their session tenant unless they + # pass an explicit tenant (deprecated fallback for unauthenticated use). + if user is not None and tenant_id is None: + tenant_id = user.tenant_id try: return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)] except store.NotFoundError as exc: @@ -380,8 +391,11 @@ def get_run( run_id: UUID, tenant_id: str | None = Query(default=None), session: Session = Depends(get_db), + user: User | None = Depends(get_optional_user), ): """Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping).""" + if user is not None and tenant_id is None: + tenant_id = user.tenant_id try: run = store.get_run(session, run_id) except store.NotFoundError as exc: @@ -408,7 +422,10 @@ def create_approval(data: ApprovalCreate, session: Session = Depends(get_db)): def list_approvals( tenant_id: str | None = Query(default=None), session: Session = Depends(get_db), + user: User | None = Depends(get_optional_user), ): + if user is not None and tenant_id is None: + tenant_id = user.tenant_id return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)] diff --git a/backend/app/main.py b/backend/app/main.py index 94c67d6..1797b1b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from pathlib import Path from fastapi import FastAPI from .flows.api import approvals_router, flows_router +from .auth.api import access_router, auth_router from .flows.sync import run_sync_blocking, watch_forever from .settings import settings @@ -65,6 +66,8 @@ app = FastAPI( app.include_router(flows_router) app.include_router(approvals_router) +app.include_router(access_router) +app.include_router(auth_router) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6ff89f5..42dcc82 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..89f0b7c --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,72 @@ +"""Access-management domain models (SQLModel tables).""" + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import Column, DateTime +from sqlmodel import Field, SQLModel + + +def utcnow() -> datetime: + return datetime.now(timezone.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()) diff --git a/backend/app/settings.py b/backend/app/settings.py index 5c59dea..173a329 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -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") @@ -30,5 +32,24 @@ class Settings(BaseSettings): # FROM the LFX process (compose: http://api:8000). callback_base_url: str = "http://localhost:8000" + # --- 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() diff --git a/backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py b/backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py new file mode 100644 index 0000000..71ef631 --- /dev/null +++ b/backend/migrations/versions/a3f1c9e2b4d7_auth_tables.py @@ -0,0 +1,82 @@ +"""auth tables (users, access requests, magic links, sessions) + +Revision ID: a3f1c9e2b4d7 +Revises: d932b6993f9a +Create Date: 2026-09-11 15:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types + + +# revision identifiers, used by Alembic. +revision: str = 'a3f1c9e2b4d7' +down_revision: Union[str, Sequence[str], None] = 'd932b6993f9a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[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') diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..fb901c2 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,228 @@ +"""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" From d4aeab8dbfbfd84ed5a73e928a4fdef32c1d0b37 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Fri, 11 Sep 2026 17:44:23 +0200 Subject: [PATCH 04/24] fix: lfx boot, deploy key, and entrypoint perms for compose/devcontainer --- Dockerfile.lfx | 8 ++++++-- PLAN_NEXT.md | 30 +++++++++++++++++++++++++++++- backend/Dockerfile | 4 +++- docker-compose.yml | 6 ++++++ frontend/Dockerfile | 8 ++++++-- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/Dockerfile.lfx b/Dockerfile.lfx index 378b23d..ffd452e 100644 --- a/Dockerfile.lfx +++ b/Dockerfile.lfx @@ -18,5 +18,9 @@ VOLUME /flows EXPOSE 6004 # 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"] diff --git a/PLAN_NEXT.md b/PLAN_NEXT.md index b0e1afc..ace771c 100644 --- a/PLAN_NEXT.md +++ b/PLAN_NEXT.md @@ -43,8 +43,36 @@ run build`, `docker compose config --quiet`. **Still open (docs-only):** `README.md` + `.env.example` uncommitted diff describes the target vision (access mgmt, dynamic forms, new Web UI) that is -NOT yet implemented: `app/auth/` is empty, `backend/components/kirche/` is +NOT yet implemented: `backend/components/kirche/` is empty, frontend is still the old 3-tab UI, no `input-schema` endpoint. +(Phase 1 backend auth IS implemented since this was written.) + +## 0.1 Follow-up fixes (commits after Phase 0) + +`fix: devcontainer build and setup on fresh clones` + `feat: magic-link +auth` + the LFX/stack repair commit: + +* `docker-outside-of-docker` needs `"moby": false` (`python:3.12-slim` is + Debian trixie, no moby-cli in its repos). Removed the manual + `/var/run/docker.sock` bind (breaks Windows, feature handles it). +* `setup.sh` used `su vscode -c` (drops env incl. `UV_PROJECT_ENVIRONMENT` + → stray `backend/.venv`, blind sync). Now `sudo -u vscode -E` helper. + Missing `.env` is provisioned from `.env.example`, not fatal. +* Compose `env_file` is `required: false` everywhere (fresh clones, CI). + Images install `curl git ca-certificates`; `.dockerignore` files keep + host `node_modules/.venv/data` out of build contexts. +* `settings.database_url` default is now a real `sqlite:///` URL (bare + path crashed `create_engine`/alembic; api could never boot uncconfigured). +* `Dockerfile.lfx` CMD dropped the positional `/flows`: `lfx>=1.12.1` + exits on an empty flow dir (restart-loop → whole stack `unhealthy`). + `lfx` service blanks `LANGFLOW_API_KEY` (server prefers it over + `LFX_API_KEY`, so backend deploys 401'd and `/flows` stayed empty). +* Both entrypoints run via explicit `sh` (git tracks them 100644; the + bind-mount shadows image perms, breaking Windows checkouts). +* Verified live: full `compose up` green (api/frontend/langflow/lfx), + startup sync deploys `backend/flows` to LFX, `uv run pytest` 60 green. + Before Reopen in Container: `docker compose down` any manual stack + (same project name → port clash). ## 1. Locked decisions (do not re-ask) diff --git a/backend/Dockerfile b/backend/Dockerfile index d31f76b..adc28ce 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -39,4 +39,6 @@ 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"] diff --git a/docker-compose.yml b/docker-compose.yml index 5ca297a..27f3bf7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,6 +121,12 @@ services: # 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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c9d2c0f..7d387c0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -5,9 +5,13 @@ FROM node:22-slim 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 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"] From 80d81b80229a335cd173b5a2326e75e47a03892a Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Fri, 11 Sep 2026 19:21:13 +0200 Subject: [PATCH 05/24] fix: postCreate failures (absolute paths, sudo HOME, socket-wait bug) --- .devcontainer/devcontainer.json | 7 +++++-- .devcontainer/setup.sh | 8 +++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 360eee8..2d14211 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,8 +23,11 @@ } }, "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": [ diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index dda5724..6cf642d 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -11,8 +11,10 @@ REPO=/srv/kirche-einfach # 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 -u vscode -E bash -c "$*" + sudo -H -u vscode -E bash -c "$*" } # Venv lives outside the bind-mount (see backend/Dockerfile). @@ -71,13 +73,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: From 46ecab00dbd7b275b0d2bdbba95c8956eed6dfd2 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Sat, 12 Sep 2026 07:36:05 +0200 Subject: [PATCH 06/24] feat: form-input library with schema parsing and endpoint --- backend/app/flows/api.py | 25 + backend/app/flows/input_schema.py | 147 +++ backend/app/flows/schemas.py | 12 + backend/app/flows/store.py | 4 + backend/app/models/flow.py | 3 + backend/components/kirche/__init__.py | 12 + backend/components/kirche/form_inputs.py | 161 +++ backend/flows/intake_form.json | 991 ++++++++++++++++++ .../b7e2d4a1c9f3_cache_form_schema.py | 32 + backend/scripts/make_flow_fixtures.py | 94 +- backend/tests/fixtures/flows/intake_form.json | 991 ++++++++++++++++++ backend/tests/test_form_components.py | 104 ++ backend/tests/test_input_schema.py | 201 ++++ 13 files changed, 2775 insertions(+), 2 deletions(-) create mode 100644 backend/app/flows/input_schema.py create mode 100644 backend/components/kirche/__init__.py create mode 100644 backend/components/kirche/form_inputs.py create mode 100644 backend/flows/intake_form.json create mode 100644 backend/migrations/versions/b7e2d4a1c9f3_cache_form_schema.py create mode 100644 backend/tests/fixtures/flows/intake_form.json create mode 100644 backend/tests/test_form_components.py create mode 100644 backend/tests/test_input_schema.py diff --git a/backend/app/flows/api.py b/backend/app/flows/api.py index 0a4dd0b..9769b75 100644 --- a/backend/app/flows/api.py +++ b/backend/app/flows/api.py @@ -18,6 +18,7 @@ from app.settings import settings from app.auth.deps import get_optional_user, resolve_tenant from . import runner, store +from . import input_schema as form_schema from .deploy import import_from_langflow from .sync import lfx_id_for, sync_from_directory from .langflow_client import ( @@ -46,6 +47,7 @@ from .schemas import ( FlowImportRequest, FlowVersionCreate, FlowVersionRead, + InputSchemaRead, RunRead, RunRequest, SyncReport, @@ -91,6 +93,7 @@ def _read_version(v) -> FlowVersionRead: deployed_at=v.deployed_at, author=v.author, created_at=v.created_at, + input_schema=v.input_schema, ) @@ -386,6 +389,28 @@ async def import_flow( ) +@flows_router.get("/{flow_id}/input-schema", response_model=InputSchemaRead) +def get_input_schema( + flow_id: UUID, + session: 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, diff --git a/backend/app/flows/input_schema.py b/backend/app/flows/input_schema.py new file mode 100644 index 0000000..863c102 --- /dev/null +++ b/backend/app/flows/input_schema.py @@ -0,0 +1,147 @@ +"""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: + # 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, + } diff --git a/backend/app/flows/schemas.py b/backend/app/flows/schemas.py index a3eb907..c3a4146 100644 --- a/backend/app/flows/schemas.py +++ b/backend/app/flows/schemas.py @@ -37,6 +37,18 @@ 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): diff --git a/backend/app/flows/store.py b/backend/app/flows/store.py index e11f2c2..eb21f3a 100644 --- a/backend/app/flows/store.py +++ b/backend/app/flows/store.py @@ -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 @@ -154,6 +155,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() diff --git a/backend/app/models/flow.py b/backend/app/models/flow.py index 14273d2..0e893ea 100644 --- a/backend/app/models/flow.py +++ b/backend/app/models/flow.py @@ -54,6 +54,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): diff --git a/backend/components/kirche/__init__.py b/backend/components/kirche/__init__.py new file mode 100644 index 0000000..252d226 --- /dev/null +++ b/backend/components/kirche/__init__.py @@ -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"] diff --git a/backend/components/kirche/form_inputs.py b/backend/components/kirche/form_inputs.py new file mode 100644 index 0000000..e9b857e --- /dev/null +++ b/backend/components/kirche/form_inputs.py @@ -0,0 +1,161 @@ +"""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 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 = [ + # 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 = [ + 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 Exception: + 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)) diff --git a/backend/flows/intake_form.json b/backend/flows/intake_form.json new file mode 100644 index 0000000..0de6f33 --- /dev/null +++ b/backend/flows/intake_form.json @@ -0,0 +1,991 @@ +{ + "data": { + "nodes": [ + { + "data": { + "node": { + "template": { + "_type": "Component", + "files": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "csv", + "json", + "pdf", + "txt", + "md", + "mdx", + "yaml", + "yml", + "xml", + "html", + "htm", + "docx", + "py", + "sh", + "sql", + "js", + "ts", + "tsx", + "jpg", + "jpeg", + "png", + "bmp", + "image" + ], + "temp_file": true, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "files", + "value": "", + "display_name": "Files", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Files to be sent with the message.", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "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": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "input_value": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Input Text", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Message to be passed as input.", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "User", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "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": "sender_name", + "value": "User", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "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": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Get chat inputs from the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Input", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Chat Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatInput", + "id": "ChatInput-entry", + "showNode": true + }, + "id": "ChatInput-entry", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 0, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "attachment": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "pdf", + "png", + "jpg" + ], + "temp_file": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "attachment", + "value": "", + "display_name": "Attachment", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "String reference only for now (no upload yet)", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "age": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "age", + "value": 18, + "display_name": "Age", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Your age in years", + "title_case": false, + "track_in_telemetry": true, + "type": "int", + "_input_type": "IntInput" + }, + "amount": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "amount", + "value": 0.0, + "display_name": "Amount", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Amount in euros", + "title_case": false, + "track_in_telemetry": true, + "type": "float", + "_input_type": "FloatInput" + }, + "department": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Music", + "Youth", + "Seniors", + "Admin" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "department", + "value": "Music", + "display_name": "Department", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Which team is this for", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "form_values": { + "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": false, + "name": "form_values", + "value": "", + "display_name": "Form values (JSON envelope)", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Submitted values as JSON; merged over the defaults", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "full_name": { + "tool_mode": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "full_name", + "value": "", + "display_name": "Full name", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Your first and last name", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "newsletter": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "newsletter", + "value": false, + "display_name": "Newsletter", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Send me the parish newsletter", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "notes": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "notes", + "value": "", + "display_name": "Notes", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Anything else we should know", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "password": { + "load_from_db": true, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "password", + "value": "", + "display_name": "Password", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Optional access phrase", + "title_case": false, + "track_in_telemetry": false, + "password": true, + "type": "str", + "_input_type": "SecretStrInput" + }, + "satisfaction": { + "tool_mode": false, + "min_label": "", + "max_label": "", + "min_label_icon": "", + "max_label_icon": "", + "slider_buttons": false, + "slider_buttons_options": [], + "slider_input": false, + "range_spec": { + "step_type": "float", + "min": 1.0, + "max": 5.0, + "step": 1.0 + }, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "satisfaction", + "value": 3, + "display_name": "Satisfaction", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "How satisfied are you (1-5)", + "title_case": false, + "track_in_telemetry": false, + "type": "slider", + "_input_type": "SliderInput" + }, + "topics": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Worship", + "Outreach", + "Courses" + ], + "combobox": false, + "toggle": false, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "topics", + "value": [], + "display_name": "Topics", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "What are you interested in", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MultiselectInput" + }, + "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": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.message import Message\n\n\ndef _text(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\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON message.\"\n icon = \"ClipboardList\"\n\n inputs = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def collect(self) -> Message:\n data = {\n \"full_name\": _text(self.full_name),\n \"notes\": _text(self.notes),\n \"password\": _text(self.password),\n \"age\": self.age,\n \"amount\": self.amount,\n \"newsletter\": self.newsletter,\n \"department\": _text(self.department),\n \"topics\": self.topics,\n \"satisfaction\": self.satisfaction,\n \"attachment\": _text(self.attachment),\n }\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except Exception:\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key in data:\n if key in override:\n data[key] = override[key]\n return Message(text=json.dumps(data, ensure_ascii=False))\n" + } + }, + "description": "Collects structured form values into a single JSON message.", + "icon": "ClipboardList", + "base_classes": [], + "display_name": "CustomInput Form", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "form_data", + "display_name": "Form Data", + "method": "collect", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "CustomInputForm", + "id": "CustomInputForm-intake", + "showNode": true + }, + "id": "CustomInputForm-intake", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 400, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_value": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Inputs", + "advanced": false, + "api_editable": false, + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "dynamic": false, + "info": "Message to be passed as output.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "clean_data": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "clean_data", + "value": true, + "display_name": "Basic Clean Data", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "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": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "data_template": { + "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": "data_template", + "value": "{text}", + "display_name": "Data Template", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "Machine", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "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": "sender_name", + "value": "AI", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "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": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Display a chat message in the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Output Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatOutput", + "id": "ChatOutput-collected", + "showNode": true + }, + "id": "ChatOutput-collected", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 800, + "y": 200 + }, + "type": "genericNode" + } + ], + "edges": [ + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "form_values", + "id": "CustomInputForm-intake", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-intakeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputForm-intake", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-intakeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputForm", + "id": "CustomInputForm-intake", + "name": "form_data", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-collected", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "CustomInputForm-intake", + "sourceHandle": "{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œMessageœ]}", + "target": "ChatOutput-collected", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + } + ], + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + } + }, + "description": "Structured intake via CustomInputForm (parsed into a web form).", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "ec795b9d-f9d2-5730-b016-16e5f9b3bd3f", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "intake_form", + "tags": [], + "webhook": false +} diff --git a/backend/migrations/versions/b7e2d4a1c9f3_cache_form_schema.py b/backend/migrations/versions/b7e2d4a1c9f3_cache_form_schema.py new file mode 100644 index 0000000..19801a9 --- /dev/null +++ b/backend/migrations/versions/b7e2d4a1c9f3_cache_form_schema.py @@ -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 typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types + + +# revision identifiers, used by Alembic. +revision: str = 'b7e2d4a1c9f3' +down_revision: Union[str, Sequence[str], None] = 'a3f1c9e2b4d7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[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") diff --git a/backend/scripts/make_flow_fixtures.py b/backend/scripts/make_flow_fixtures.py index 3a369b6..b4b7b81 100644 --- a/backend/scripts/make_flow_fixtures.py +++ b/backend/scripts/make_flow_fixtures.py @@ -68,8 +68,7 @@ def builtin_node( return node_dict -def custom_node( - code: str, +def custom_node( code: str, node_id: str, values: dict | None = None, position: tuple[int, int] = (0, 0), @@ -87,6 +86,66 @@ 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 + return node_dict + + # -------------------------------------------------------------------------- # edges # -------------------------------------------------------------------------- @@ -433,12 +492,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" diff --git a/backend/tests/fixtures/flows/intake_form.json b/backend/tests/fixtures/flows/intake_form.json new file mode 100644 index 0000000..0de6f33 --- /dev/null +++ b/backend/tests/fixtures/flows/intake_form.json @@ -0,0 +1,991 @@ +{ + "data": { + "nodes": [ + { + "data": { + "node": { + "template": { + "_type": "Component", + "files": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "csv", + "json", + "pdf", + "txt", + "md", + "mdx", + "yaml", + "yml", + "xml", + "html", + "htm", + "docx", + "py", + "sh", + "sql", + "js", + "ts", + "tsx", + "jpg", + "jpeg", + "png", + "bmp", + "image" + ], + "temp_file": true, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "files", + "value": "", + "display_name": "Files", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Files to be sent with the message.", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from lfx.base.data.utils import IMG_FILE_TYPES, TEXT_FILE_TYPES\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.inputs.inputs import BoolInput\nfrom lfx.io import (\n DropdownInput,\n FileInput,\n MessageTextInput,\n MultilineInput,\n Output,\n)\nfrom lfx.schema.image import get_file_paths\nfrom lfx.schema.message import Message\nfrom lfx.services.deps import get_settings_service\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_USER,\n MESSAGE_SENDER_USER,\n)\nfrom lfx.utils.file_path_security import (\n component_file_access_scopes,\n enforce_local_file_access,\n is_local_file_access_restricted,\n)\n\n\nclass ChatInput(ChatComponent):\n display_name = \"Chat Input\"\n description = \"Get chat inputs from the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatInput\"\n minimized = True\n\n inputs = [\n MultilineInput(\n name=\"input_value\",\n display_name=\"Input Text\",\n value=\"\",\n info=\"Message to be passed as input.\",\n input_types=[],\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_USER,\n info=\"Type of sender.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_USER,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n FileInput(\n name=\"files\",\n display_name=\"Files\",\n file_types=TEXT_FILE_TYPES + IMG_FILE_TYPES,\n info=\"Files to be sent with the message.\",\n advanced=True,\n is_list=True,\n temp_file=True,\n ),\n ]\n outputs = [\n Output(display_name=\"Chat Message\", name=\"message\", method=\"message_response\"),\n ]\n\n async def message_response(self) -> Message:\n # Ensure files is a list and filter out empty/None values\n files = self.files if self.files else []\n if files and not isinstance(files, list):\n files = [files]\n # Filter out None/empty values\n files = [f for f in files if f is not None and f != \"\"]\n\n # Build endpoints inject caller-controlled file references directly into\n # ChatInput. Resolve and validate local references before the Message can\n # later read them while constructing model attachment content.\n if files:\n settings_service = get_settings_service()\n storage_type = getattr(getattr(settings_service, \"settings\", None), \"storage_type\", \"local\")\n # The storage factory falls back to local storage for unsupported\n # values, so only the explicitly configured S3 backend may bypass\n # filesystem containment.\n if str(storage_type).lower() != \"s3\" and is_local_file_access_restricted():\n scope_ids = list(component_file_access_scopes(self))\n # Public executions replace graph.flow_id with a per-visitor\n # virtual ID. Only the server-populated source provenance may\n # restore the already-validated public attachment namespace.\n source_flow_id = getattr(self.graph, \"source_flow_id\", None)\n if source_flow_id:\n scope_ids.append(source_flow_id)\n for file_path in get_file_paths(files):\n enforce_local_file_access(file_path, scope_ids=scope_ids)\n\n session_id = self.session_id or self.graph.session_id or \"\"\n message = await Message.create(\n text=self.input_value,\n sender=self.sender,\n sender_name=self.sender_name,\n session_id=session_id,\n context_id=self.context_id,\n files=files,\n )\n if session_id and isinstance(message, Message) and self.should_store_message:\n stored_message = await self.send_message(\n message,\n )\n self.message.value = stored_message\n message = stored_message\n\n self.status = message\n return message\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "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": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "input_value": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Input Text", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Message to be passed as input.", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "User", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "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": "sender_name", + "value": "User", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "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": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Get chat inputs from the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Input", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Chat Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatInput", + "id": "ChatInput-entry", + "showNode": true + }, + "id": "ChatInput-entry", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 0, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "attachment": { + "tool_mode": false, + "trace_as_metadata": true, + "file_path": "", + "fileTypes": [ + "pdf", + "png", + "jpg" + ], + "temp_file": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "attachment", + "value": "", + "display_name": "Attachment", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "String reference only for now (no upload yet)", + "title_case": false, + "track_in_telemetry": false, + "type": "file", + "_input_type": "FileInput" + }, + "age": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "age", + "value": 18, + "display_name": "Age", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Your age in years", + "title_case": false, + "track_in_telemetry": true, + "type": "int", + "_input_type": "IntInput" + }, + "amount": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "amount", + "value": 0.0, + "display_name": "Amount", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Amount in euros", + "title_case": false, + "track_in_telemetry": true, + "type": "float", + "_input_type": "FloatInput" + }, + "department": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Music", + "Youth", + "Seniors", + "Admin" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "department", + "value": "Music", + "display_name": "Department", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Which team is this for", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "form_values": { + "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": false, + "name": "form_values", + "value": "", + "display_name": "Form values (JSON envelope)", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Submitted values as JSON; merged over the defaults", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "full_name": { + "tool_mode": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "full_name", + "value": "", + "display_name": "Full name", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Your first and last name", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "StrInput" + }, + "newsletter": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "newsletter", + "value": false, + "display_name": "Newsletter", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "Send me the parish newsletter", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "notes": { + "tool_mode": false, + "trace_as_input": true, + "multiline": true, + "ai_enabled": false, + "trace_as_metadata": true, + "load_from_db": false, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "notes", + "value": "", + "display_name": "Notes", + "advanced": false, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Anything else we should know", + "title_case": false, + "track_in_telemetry": false, + "copy_field": false, + "password": false, + "type": "str", + "_input_type": "MultilineInput" + }, + "password": { + "load_from_db": true, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "password", + "value": "", + "display_name": "Password", + "advanced": false, + "api_editable": false, + "input_types": [], + "dynamic": false, + "info": "Optional access phrase", + "title_case": false, + "track_in_telemetry": false, + "password": true, + "type": "str", + "_input_type": "SecretStrInput" + }, + "satisfaction": { + "tool_mode": false, + "min_label": "", + "max_label": "", + "min_label_icon": "", + "max_label_icon": "", + "slider_buttons": false, + "slider_buttons_options": [], + "slider_input": false, + "range_spec": { + "step_type": "float", + "min": 1.0, + "max": 5.0, + "step": 1.0 + }, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "satisfaction", + "value": 3, + "display_name": "Satisfaction", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "How satisfied are you (1-5)", + "title_case": false, + "track_in_telemetry": false, + "type": "slider", + "_input_type": "SliderInput" + }, + "topics": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Worship", + "Outreach", + "Courses" + ], + "combobox": false, + "toggle": false, + "list": true, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "topics", + "value": [], + "display_name": "Topics", + "advanced": false, + "api_editable": false, + "dynamic": false, + "info": "What are you interested in", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MultiselectInput" + }, + "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": "\"\"\"Structured form-input component for kirche-einfach flows.\n\nA flow that starts with :class:`CustomInputForm` declares the fields of its\nweb form. The API reads the exported node template\n(``template`` minus ``code``) and derives ``{json_schema, ui_schema}`` from\nthe ``lfx.io`` input definitions below — one JSON-Schema property per field,\nkeyed by ``name``. Field metadata maps as follows:\n\n* ``display_name`` → ``title``, ``info`` → ``description``,\n ``value`` → ``default``, ``options`` → ``enum``\n* ``required`` → required array, ``advanced`` → ``ui:collapsed`` hint\n* ``_input_type`` → JSON type + widget (see ``app/flows/input_schema.py``)\n\n``FileInput`` is a stub for now: it carries a string reference only, real\nupload comes later.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\n\nfrom lfx.custom import Component\nfrom lfx.field_typing.range_spec import RangeSpec\nfrom lfx.io import (\n BoolInput,\n DropdownInput,\n FileInput,\n FloatInput,\n IntInput,\n MessageTextInput,\n MultilineInput,\n MultiselectInput,\n Output,\n SecretStrInput,\n SliderInput,\n StrInput,\n)\nfrom lfx.schema.message import Message\n\n\ndef _text(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\nclass CustomInputForm(Component):\n display_name = \"CustomInput Form\"\n description = \"Collects structured form values into a single JSON message.\"\n icon = \"ClipboardList\"\n\n inputs = [\n # Plumbing, not a form field: the API posts submitted web-form\n # values as the run envelope (same {\"payload\": {...}} shape the\n # other kirche components use); they arrive here through a ChatInput\n # entry node and override the defaults below. Hidden in the GUI and\n # skipped by the form parser (show=False).\n MessageTextInput(\n name=\"form_values\",\n display_name=\"Form values (JSON envelope)\",\n info=\"Submitted values as JSON; merged over the defaults\",\n value=\"\",\n show=False,\n ),\n StrInput(\n name=\"full_name\",\n display_name=\"Full name\",\n info=\"Your first and last name\",\n value=\"\",\n required=True,\n ),\n MultilineInput(\n name=\"notes\",\n display_name=\"Notes\",\n info=\"Anything else we should know\",\n value=\"\",\n ),\n SecretStrInput(\n name=\"password\",\n display_name=\"Password\",\n info=\"Optional access phrase\",\n value=\"\",\n ),\n IntInput(\n name=\"age\",\n display_name=\"Age\",\n info=\"Your age in years\",\n value=18,\n ),\n FloatInput(\n name=\"amount\",\n display_name=\"Amount\",\n info=\"Amount in euros\",\n value=0.0,\n ),\n BoolInput(\n name=\"newsletter\",\n display_name=\"Newsletter\",\n info=\"Send me the parish newsletter\",\n value=False,\n ),\n DropdownInput(\n name=\"department\",\n display_name=\"Department\",\n info=\"Which team is this for\",\n options=[\"Music\", \"Youth\", \"Seniors\", \"Admin\"],\n value=\"Music\",\n ),\n MultiselectInput(\n name=\"topics\",\n display_name=\"Topics\",\n info=\"What are you interested in\",\n options=[\"Worship\", \"Outreach\", \"Courses\"],\n value=[],\n ),\n SliderInput(\n name=\"satisfaction\",\n display_name=\"Satisfaction\",\n info=\"How satisfied are you (1-5)\",\n value=3,\n range_spec=RangeSpec(min=1, max=5, step=1),\n ),\n FileInput(\n name=\"attachment\",\n display_name=\"Attachment\",\n info=\"String reference only for now (no upload yet)\",\n file_types=[\"pdf\", \"png\", \"jpg\"],\n ),\n ]\n outputs = [\n Output(display_name=\"Form Data\", name=\"form_data\", method=\"collect\"),\n ]\n\n def collect(self) -> Message:\n data = {\n \"full_name\": _text(self.full_name),\n \"notes\": _text(self.notes),\n \"password\": _text(self.password),\n \"age\": self.age,\n \"amount\": self.amount,\n \"newsletter\": self.newsletter,\n \"department\": _text(self.department),\n \"topics\": self.topics,\n \"satisfaction\": self.satisfaction,\n \"attachment\": _text(self.attachment),\n }\n # Submitted values (via form_values) win over the field defaults.\n # Accepts the raw dict or the run envelope {\"payload\": {...}}.\n try:\n override = json.loads(_text(self.form_values) or \"{}\")\n except Exception:\n override = {}\n if isinstance(override, dict):\n if isinstance(override.get(\"payload\"), dict):\n override = override[\"payload\"]\n for key in data:\n if key in override:\n data[key] = override[key]\n return Message(text=json.dumps(data, ensure_ascii=False))\n" + } + }, + "description": "Collects structured form values into a single JSON message.", + "icon": "ClipboardList", + "base_classes": [], + "display_name": "CustomInput Form", + "documentation": "", + "minimized": false, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "form_data", + "display_name": "Form Data", + "method": "collect", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "CustomInputForm", + "id": "CustomInputForm-intake", + "showNode": true + }, + "id": "CustomInputForm-intake", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 400, + "y": 200 + }, + "type": "genericNode" + }, + { + "data": { + "node": { + "template": { + "_type": "Component", + "input_value": { + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": true, + "placeholder": "", + "show": true, + "name": "input_value", + "value": "", + "display_name": "Inputs", + "advanced": false, + "api_editable": false, + "input_types": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "dynamic": false, + "info": "Message to be passed as output.", + "title_case": false, + "track_in_telemetry": false, + "type": "other", + "_input_type": "HandleInput" + }, + "clean_data": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "clean_data", + "value": true, + "display_name": "Basic Clean Data", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Whether to clean data before converting to string.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + }, + "code": { + "type": "code", + "required": true, + "placeholder": "", + "list": false, + "show": true, + "multiline": true, + "value": "from collections.abc import Generator\nfrom typing import Any\n\nimport orjson\nfrom fastapi.encoders import jsonable_encoder\n\nfrom lfx.base.io.chat import ChatComponent\nfrom lfx.helpers.data import safe_convert\nfrom lfx.inputs.inputs import BoolInput, DropdownInput, HandleInput, MessageTextInput\nfrom lfx.schema.data import Data\nfrom lfx.schema.dataframe import DataFrame\nfrom lfx.schema.message import Message\nfrom lfx.schema.properties import Source\nfrom lfx.template.field.base import Output\nfrom lfx.utils.constants import (\n MESSAGE_SENDER_AI,\n MESSAGE_SENDER_NAME_AI,\n MESSAGE_SENDER_USER,\n)\n\n\nclass ChatOutput(ChatComponent):\n display_name = \"Chat Output\"\n description = \"Display a chat message in the Playground.\"\n documentation: str = \"https://docs.langflow.org/chat-input-and-output\"\n icon = \"MessagesSquare\"\n name = \"ChatOutput\"\n minimized = True\n\n inputs = [\n HandleInput(\n name=\"input_value\",\n display_name=\"Inputs\",\n info=\"Message to be passed as output.\",\n input_types=[\"Data\", \"JSON\", \"DataFrame\", \"Table\", \"Message\"],\n required=True,\n ),\n BoolInput(\n name=\"should_store_message\",\n display_name=\"Store Messages\",\n info=\"Store the message in the history.\",\n value=True,\n advanced=True,\n ),\n DropdownInput(\n name=\"sender\",\n display_name=\"Sender Type\",\n options=[MESSAGE_SENDER_AI, MESSAGE_SENDER_USER],\n value=MESSAGE_SENDER_AI,\n advanced=True,\n info=\"Type of sender.\",\n ),\n MessageTextInput(\n name=\"sender_name\",\n display_name=\"Sender Name\",\n info=\"Name of the sender.\",\n value=MESSAGE_SENDER_NAME_AI,\n advanced=True,\n ),\n MessageTextInput(\n name=\"session_id\",\n display_name=\"Session ID\",\n info=\"The session ID of the chat. If empty, the current session ID parameter will be used.\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"context_id\",\n display_name=\"Context ID\",\n info=\"The context ID of the chat. Adds an extra layer to the local memory.\",\n value=\"\",\n advanced=True,\n ),\n MessageTextInput(\n name=\"data_template\",\n display_name=\"Data Template\",\n value=\"{text}\",\n advanced=True,\n info=\"Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.\",\n ),\n BoolInput(\n name=\"clean_data\",\n display_name=\"Basic Clean Data\",\n value=True,\n advanced=True,\n info=\"Whether to clean data before converting to string.\",\n ),\n ]\n outputs = [\n Output(\n display_name=\"Output Message\",\n name=\"message\",\n method=\"message_response\",\n ),\n ]\n\n def _build_source(self, id_: str | None, display_name: str | None, source: str | None) -> Source:\n source_dict = {}\n if id_:\n source_dict[\"id\"] = id_\n if display_name:\n source_dict[\"display_name\"] = display_name\n if source:\n # Handle case where source is a ChatOpenAI object\n if hasattr(source, \"model_name\"):\n source_dict[\"source\"] = source.model_name\n elif hasattr(source, \"model\"):\n source_dict[\"source\"] = str(source.model)\n else:\n source_dict[\"source\"] = str(source)\n return Source(**source_dict)\n\n async def message_response(self) -> Message:\n # First convert the input to string if needed\n text = self.convert_to_string()\n\n # Get source properties\n source, _, display_name, source_id = self.get_properties_from_source_component()\n\n # Create or use existing Message object\n if isinstance(self.input_value, Message) and not self.is_connected_to_chat_input():\n message = self.input_value\n # Update message properties\n message.text = text\n # Preserve existing session_id from the incoming message if it exists\n existing_session_id = message.session_id\n else:\n message = Message(text=text)\n existing_session_id = None\n\n # Set message properties\n message.sender = self.sender\n message.sender_name = self.sender_name\n # Preserve session_id from incoming message, or use component/graph session_id\n message.session_id = (\n self.session_id or existing_session_id or (self.graph.session_id if hasattr(self, \"graph\") else None) or \"\"\n )\n message.context_id = self.context_id\n message.flow_id = self.graph.flow_id if hasattr(self, \"graph\") else None\n message.properties.source = self._build_source(source_id, display_name, source)\n\n # Store message if needed\n if message.session_id and self.should_store_message:\n stored_message = await self.send_message(message)\n self.message.value = stored_message\n message = stored_message\n\n # Set accumulated token usage from all upstream LLM vertices.\n # This must happen AFTER send_message() because streaming captures\n # usage from chunks and would overwrite accumulated totals.\n if hasattr(self, \"_vertex\") and self._vertex is not None:\n accumulated_usage = self._vertex._accumulate_upstream_token_usage() # noqa: SLF001\n if accumulated_usage:\n message.properties.usage = accumulated_usage\n if self.should_store_message and message.get_id():\n message = await self._update_stored_message(message)\n await self._send_message_event(message, id_=message.get_id())\n\n self.status = message\n return message\n\n def _serialize_data(self, data: Data) -> str:\n \"\"\"Serialize Data object to JSON string.\"\"\"\n # Convert data.data to JSON-serializable format\n serializable_data = jsonable_encoder(data.data)\n # Serialize with orjson, enabling pretty printing with indentation\n json_bytes = orjson.dumps(serializable_data, option=orjson.OPT_INDENT_2)\n # Convert bytes to string and wrap in Markdown code blocks\n return \"```json\\n\" + json_bytes.decode(\"utf-8\") + \"\\n```\"\n\n def _validate_input(self) -> None:\n \"\"\"Validate the input data and raise ValueError if invalid.\"\"\"\n if self.input_value is None:\n msg = \"Input data cannot be None\"\n raise ValueError(msg)\n if isinstance(self.input_value, list) and not all(\n isinstance(item, Message | Data | DataFrame | str) for item in self.input_value\n ):\n invalid_types = [\n type(item).__name__\n for item in self.input_value\n if not isinstance(item, Message | Data | DataFrame | str)\n ]\n msg = f\"Expected Data or DataFrame or Message or str, got {invalid_types}\"\n raise TypeError(msg)\n if not isinstance(\n self.input_value,\n Message | Data | DataFrame | str | list | Generator | type(None),\n ):\n type_name = type(self.input_value).__name__\n msg = f\"Expected Data or DataFrame or Message or str, Generator or None, got {type_name}\"\n raise TypeError(msg)\n\n def convert_to_string(self) -> str | Generator[Any, None, None]:\n \"\"\"Convert input data to string with proper error handling.\"\"\"\n self._validate_input()\n if isinstance(self.input_value, list):\n clean_data: bool = getattr(self, \"clean_data\", False)\n return \"\\n\".join([safe_convert(item, clean_data=clean_data) for item in self.input_value])\n if isinstance(self.input_value, Generator):\n return self.input_value\n return safe_convert(self.input_value)\n", + "fileTypes": [], + "file_path": "", + "password": false, + "name": "code", + "advanced": true, + "api_editable": false, + "dynamic": true, + "info": "", + "load_from_db": false, + "title_case": false + }, + "context_id": { + "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": "context_id", + "value": "", + "display_name": "Context ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The context ID of the chat. Adds an extra layer to the local memory.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "data_template": { + "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": "data_template", + "value": "{text}", + "display_name": "Data Template", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Template to convert Data to Text. If left empty, it will be dynamically set to the Data's text key.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "sender": { + "tool_mode": false, + "trace_as_metadata": true, + "options": [ + "Machine", + "User" + ], + "options_metadata": [], + "combobox": false, + "dialog_inputs": {}, + "toggle": false, + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "sender", + "value": "Machine", + "display_name": "Sender Type", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Type of sender.", + "title_case": false, + "track_in_telemetry": true, + "external_options": {}, + "type": "str", + "_input_type": "DropdownInput" + }, + "sender_name": { + "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": "sender_name", + "value": "AI", + "display_name": "Sender Name", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "Name of the sender.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "session_id": { + "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": "session_id", + "value": "", + "display_name": "Session ID", + "advanced": true, + "api_editable": false, + "input_types": [ + "Message" + ], + "dynamic": false, + "info": "The session ID of the chat. If empty, the current session ID parameter will be used.", + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "_input_type": "MessageTextInput" + }, + "should_store_message": { + "tool_mode": false, + "trace_as_metadata": true, + "list": false, + "list_add_label": "Add More", + "override_skip": false, + "required": false, + "placeholder": "", + "show": true, + "name": "should_store_message", + "value": true, + "display_name": "Store Messages", + "advanced": true, + "api_editable": false, + "dynamic": false, + "info": "Store the message in the history.", + "title_case": false, + "track_in_telemetry": true, + "type": "bool", + "_input_type": "BoolInput" + } + }, + "description": "Display a chat message in the Playground.", + "icon": "MessagesSquare", + "base_classes": [ + "Message" + ], + "display_name": "Chat Output", + "documentation": "https://docs.langflow.org/chat-input-and-output", + "minimized": true, + "custom_fields": {}, + "output_types": [], + "pinned": false, + "conditional_paths": [], + "frozen": false, + "outputs": [ + { + "types": [ + "Message" + ], + "selected": "Message", + "name": "message", + "display_name": "Output Message", + "method": "message_response", + "value": "__UNDEFINED__", + "cache": true, + "allows_loop": false, + "group_outputs": false, + "tool_mode": true + } + ], + "field_order": [], + "beta": false, + "legacy": false, + "edited": false, + "metadata": {}, + "tool_mode": false + }, + "type": "ChatOutput", + "id": "ChatOutput-collected", + "showNode": true + }, + "id": "ChatOutput-collected", + "measured": { + "width": 320, + "height": 200 + }, + "position": { + "x": 800, + "y": 200 + }, + "type": "genericNode" + } + ], + "edges": [ + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "ChatInput", + "id": "ChatInput-entry", + "name": "message", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "form_values", + "id": "CustomInputForm-intake", + "inputTypes": [ + "Message" + ], + "type": "str" + } + }, + "id": "xy-edge__{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-intakeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}", + "selected": false, + "source": "ChatInput-entry", + "sourceHandle": "{œdataTypeœ: œChatInputœ, œidœ: œChatInput-entryœ, œnameœ: œmessageœ, œoutput_typesœ: [œMessageœ]}", + "target": "CustomInputForm-intake", + "targetHandle": "{œfieldNameœ: œform_valuesœ, œidœ: œCustomInputForm-intakeœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}" + }, + { + "animated": false, + "className": "", + "data": { + "sourceHandle": { + "dataType": "CustomInputForm", + "id": "CustomInputForm-intake", + "name": "form_data", + "output_types": [ + "Message" + ] + }, + "targetHandle": { + "fieldName": "input_value", + "id": "ChatOutput-collected", + "inputTypes": [ + "Data", + "JSON", + "DataFrame", + "Table", + "Message" + ], + "type": "other" + } + }, + "id": "xy-edge__{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}", + "selected": false, + "source": "CustomInputForm-intake", + "sourceHandle": "{œdataTypeœ: œCustomInputFormœ, œidœ: œCustomInputForm-intakeœ, œnameœ: œform_dataœ, œoutput_typesœ: [œMessageœ]}", + "target": "ChatOutput-collected", + "targetHandle": "{œfieldNameœ: œinput_valueœ, œidœ: œChatOutput-collectedœ, œinputTypesœ: [œDataœ, œJSONœ, œDataFrameœ, œTableœ, œMessageœ], œtypeœ: œotherœ}" + } + ], + "viewport": { + "x": 0, + "y": 0, + "zoom": 1 + } + }, + "description": "Structured intake via CustomInputForm (parsed into a web form).", + "endpoint_name": null, + "icon": null, + "icon_bg_color": null, + "id": "ec795b9d-f9d2-5730-b016-16e5f9b3bd3f", + "is_component": false, + "locked": false, + "mcp_enabled": false, + "name": "intake_form", + "tags": [], + "webhook": false +} diff --git a/backend/tests/test_form_components.py b/backend/tests/test_form_components.py new file mode 100644 index 0000000..91f0a20 --- /dev/null +++ b/backend/tests/test_form_components.py @@ -0,0 +1,104 @@ +"""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 # noqa: E402 + +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): + 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 diff --git a/backend/tests/test_input_schema.py b/backend/tests/test_input_schema.py new file mode 100644 index 0000000..1cb7fd5 --- /dev/null +++ b/backend/tests/test_input_schema.py @@ -0,0 +1,201 @@ +"""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): + assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404 + + 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"} From 62d01b5097c2662ed6397c5ce5a3f7e97c191b12 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Sat, 12 Sep 2026 07:36:05 +0200 Subject: [PATCH 07/24] feat: router with RJSF workflows and access management UI --- PLAN_NEXT.md | 41 +++- frontend/package-lock.json | 273 +++++++++++++++++++++++- frontend/package.json | 5 +- frontend/src/App.tsx | 81 ++++--- frontend/src/api.ts | 65 +++++- frontend/src/auth.tsx | 55 +++++ frontend/src/components/AccessQueue.tsx | 90 ++++++++ frontend/src/components/FlowsTab.tsx | 105 --------- frontend/src/main.tsx | 5 +- frontend/src/pages/AccessPage.tsx | 88 ++++++++ frontend/src/pages/AdminPage.tsx | 32 +++ frontend/src/pages/LoginPage.tsx | 44 ++++ frontend/src/pages/StartPage.tsx | 26 +++ frontend/src/pages/TasksPage.tsx | 45 ++++ frontend/src/pages/WorkflowsPage.tsx | 137 ++++++++++++ 15 files changed, 948 insertions(+), 144 deletions(-) create mode 100644 frontend/src/auth.tsx create mode 100644 frontend/src/components/AccessQueue.tsx delete mode 100644 frontend/src/components/FlowsTab.tsx create mode 100644 frontend/src/pages/AccessPage.tsx create mode 100644 frontend/src/pages/AdminPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/StartPage.tsx create mode 100644 frontend/src/pages/TasksPage.tsx create mode 100644 frontend/src/pages/WorkflowsPage.tsx diff --git a/PLAN_NEXT.md b/PLAN_NEXT.md index ace771c..dc760ca 100644 --- a/PLAN_NEXT.md +++ b/PLAN_NEXT.md @@ -41,11 +41,9 @@ build`, `docker compose config`, container rebuild. CI covers all three pytest` (attached api, from `backend/`), `docker compose exec frontend npm run build`, `docker compose config --quiet`. -**Still open (docs-only):** `README.md` + `.env.example` uncommitted diff -describes the target vision (access mgmt, dynamic forms, new Web UI) that is -NOT yet implemented: `backend/components/kirche/` is -empty, frontend is still the old 3-tab UI, no `input-schema` endpoint. -(Phase 1 backend auth IS implemented since this was written.) +**Still open:** nothing from the original list — Phases 1–4 are implemented +(see §0.2). Remaining eyeball checks: `kirche` category in the Langflow +palette, RJSF form UX in a browser, magic-link mail with real SMTP. ## 0.1 Follow-up fixes (commits after Phase 0) @@ -73,10 +71,39 @@ auth` + the LFX/stack repair commit: startup sync deploys `backend/flows` to LFX, `uv run pytest` 60 green. Before Reopen in Container: `docker compose down` any manual stack (same project name → port clash). +## 0.2 Phases 1–4 (done, with deviations) -## 1. Locked decisions (do not re-ask) +* Phase 1 auth as planned (`app/auth/*`, migration `a3f1c9e2b4d7`). + Additionally fixed: `settings.database_url` default is a real + `sqlite:///` URL (bare path crashed `create_engine`/alembic). +* Phase 2: `CustomInputForm` has the 10 planned inputs **plus one hidden + `form_values: MessageTextInput (show=False)` plumbing input**. Without + it, submitted web-form values never reached the run (LFX routes run + input to entry nodes; scalar fields are not connectable). Fixture flow + `intake_form.json`: `ChatInput → form.form_values`, + `form.form_data → ChatOutput`; `collect()` merges the envelope + (`{"payload": {...}}` unwrapped like the other components) over field + defaults. Verified live: run with values returns them in the output. +* Phase 2 generator caveat: lfx 1.12.x `to_frontend_node()` **cannot + re-instantiate `MultiselectInput`** (emits `list`, constructor forbids + extras — their own builtin `SearXNGToolComponent` fails identically). + `kirche_node()` therefore replicates the pre-validation dict build and + appends the `code` field by hand. If a future lfx fixes this, simplify + back to plain `to_frontend_node()`. +* Phase 3 as planned (`input_schema.py`, `FlowVersion.input_schema`, + migration `b7e2d4a1c9f3`, `GET input-schema`). Deviations: schema is + cached in `store.create_version` (single choke point: sync, import, API + all covered); parser skips `show: false` fields; enum-mismatched + defaults omitted; endpoint maps `InvalidError` → 404 too + (`get_latest_deployed_version` raises it, not `NotFoundError`). +* Phase 4 as planned (`react-router-dom@7`, `@rjsf/core` + + `@rjsf/validator-ajv8` (v6), English routes, German labels, + `credentials: include`, old tab shell dropped). `FlowsTab.tsx` deleted; + `ApprovalsTab`/`AdminTab` reused inside Tasks/Admin pages. Verified: + `npm run build` green, routes + `/api` proxy return 200, served schema + validates under JSON-Schema Draft 7. -* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`, +## 1. Locked decisions (do not re-ask)* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`, store `sha256`, `used_at` set before session creation), session = opaque id in **DB table**, cookie `HttpOnly; Secure; SameSite=Lax; Path=/`. SMTP creds already in `.env` (`SMTP_HOST/PORT/USERNAME/PASSWORD`, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7315980..4719b52 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,11 @@ "name": "kirche-einfach-frontend", "version": "0.1.0", "dependencies": { + "@rjsf/core": "^6.10.0", + "@rjsf/validator-ajv8": "^6.10.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-router-dom": "^7.18.3" }, "devDependencies": { "@types/react": "^19.0.0", @@ -810,6 +813,58 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@rjsf/core": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.10.0.tgz", + "integrity": "sha512-fdyaPnhIe+NzTFZcYcYcp/QqVcAJhhloIsU6vN+hp/IhiGJrzgB9REMVVUlKYNzP37xZ/BsEq7u7RC24KKdLAQ==", + "license": "Apache-2.0", + "dependencies": { + "markdown-to-jsx": "^9.8.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.10.0", + "react": ">=18" + } + }, + "node_modules/@rjsf/utils": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz", + "integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@x0k/json-schema-merge": "^1.0.3", + "fast-equals": "^6.0.0", + "fast-uri": "^4.1.4", + "jsonpointer": "^5.0.1", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@rjsf/validator-ajv8": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-6.10.0.tgz", + "integrity": "sha512-B/cNuPIQNBiJo4ByoJUXwGylu51JA5Jhkj4gsZ794dl85xxfcQ43HKfR1Lg55SoCUOzHc0Sf7zI/0q3R3sKEEQ==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^2.1.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@rjsf/utils": "^6.10.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1219,6 +1274,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -1260,6 +1322,65 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@x0k/json-schema-merge": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.5.tgz", + "integrity": "sha512-bPvwYpTkhBOXkvbC8KMNhkgzv+gWu6Yx6FVho3mL+vMf8Fy3gdn9YrlrBrXphznvOHhB85BudH/ywtVRZ/HG3A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.21", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", @@ -1335,6 +1456,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1419,6 +1553,39 @@ "node": ">=6" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.3.tgz", + "integrity": "sha512-IECu4C0fFZFoUC2cA2rDaNUuVIfWIZMwh3tY+ng924vOuD9OaUFFrcIrVwCKbV7TJDkGmrHfhwZ7R0AnHfVhyw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-uri": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", + "integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1482,6 +1649,12 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1495,6 +1668,16 @@ "node": ">=6" } }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1505,6 +1688,34 @@ "yallist": "^3.0.2" } }, + "node_modules/markdown-to-jsx": { + "version": "9.10.2", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-9.10.2.tgz", + "integrity": "sha512-iR9GadlIox0q1uXnpqdxpF02Vb1WDmZ/QIXWjBR5htzjUEEhyIWbM65LuVKavdwJaVo4q95C/F2OOyNjWe91ig==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "react": ">= 16.0.0", + "solid-js": ">=1.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1611,6 +1822,13 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1621,6 +1839,53 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -1683,6 +1948,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 03a8bf0..32ac6c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,8 +9,11 @@ "preview": "vite preview" }, "dependencies": { + "@rjsf/core": "^6.10.0", + "@rjsf/validator-ajv8": "^6.10.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-router-dom": "^7.18.3" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8ebbdb..7d2c87e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,39 +1,66 @@ -import { useState } from "react"; -import AdminTab from "./components/AdminTab"; -import ApprovalsTab from "./components/ApprovalsTab"; -import FlowsTab from "./components/FlowsTab"; +import { Link, Route, Routes, useNavigate } from "react-router-dom"; +import { AuthProvider, useAuth } from "./auth"; +import StartPage from "./pages/StartPage"; +import AccessPage from "./pages/AccessPage"; +import LoginPage from "./pages/LoginPage"; +import WorkflowsPage from "./pages/WorkflowsPage"; +import TasksPage from "./pages/TasksPage"; +import AdminPage from "./pages/AdminPage"; -type Tab = "flows" | "approvals" | "admin"; +const navStyle: React.CSSProperties = { + display: "flex", + gap: 12, + marginBottom: 16, + alignItems: "center", +}; -const tabStyle = (active: boolean): React.CSSProperties => ({ - fontWeight: active ? "bold" : "normal", - textDecoration: active ? "underline" : "none", -}); +function Shell() { + const { me, loading, logout } = useAuth(); + const navigate = useNavigate(); -export default function App() { - const [tab, setTab] = useState("flows"); + async function onLogout() { + await logout(); + navigate("/"); + } return (

Kirche Einfach

KI-gestützte Verwaltungsplattform für Kirche

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

Zugangsanfragen

+ {error &&

{error}

} + {info &&

{info}

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

Keine offenen Anfragen.

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

Entschieden ({done.length})

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

Vorgang starten

- {error &&

{error}

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