diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2d14211..41ed32c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,23 +11,14 @@ }, "mounts": [ "source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly", - "source=kirche-opencode-share,target=/home/vscode/.local/share/opencode,type=volume", - "source=kirche-opencode-config,target=/home/vscode/.config/opencode,type=volume" + "source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind" ], "features": { - // 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 - } + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} }, "overrideCommand": false, - // 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", + "postCreateCommand": "sudo -E bash .devcontainer/setup.sh", + "postStartCommand": "bash .devcontainer/postStart.sh", "customizations": { "vscode": { "extensions": [ @@ -38,7 +29,7 @@ "esbenp.prettier-vscode" ], "settings": { - "python.defaultInterpreterPath": "/opt/venv/bin/python" + "python.defaultInterpreterPath": "/srv/kirche-einfach/backend/.venv/bin/python" } } } diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index 9e9b0b3..d002adf 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -2,73 +2,31 @@ # 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 -eo pipefail +set -e REPO=/srv/kirche-einfach -# Run a command as vscode with the current environment intact. -# (`su vscode -c` resets the environment, which used to drop -# UV_PROJECT_ENVIRONMENT/UV_PYTHON_INSTALL_DIR plus every compose-provided -# variable — uv then created a project-local backend/.venv in the bind-mount -# instead of using /opt/venv, and sync_flows.py ran without its settings.) -# -H repoints HOME to /home/vscode: without it HOME stays /root (from the -# outer sudo) and uv/pip caches fail with Permission denied. -as_vscode() { - sudo -H -u vscode -E bash -c "$*" -} - -# Venv lives outside the bind-mount (see backend/Dockerfile). -export UV_PYTHON_INSTALL_DIR=/opt/python UV_PROJECT_ENVIRONMENT=/opt/venv - echo "=== Setting up kirche-einfach ===" -# 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). +# Backend deps into the venv volume (setup runs via sudo; handed to vscode below). echo "--- Installing Python dependencies ---" cd "$REPO/backend" -as_vscode "uv sync" +uv sync # opencode CLI (dev tooling; intentionally not baked into the image). if [ ! -x /home/vscode/.opencode/bin/opencode ]; then echo "--- Installing opencode ---" - as_vscode "curl -fsSL https://opencode.ai/install | bash" + su vscode -c "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.) -# 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 "WARN: $REPO/.env missing — copying .env.example (fill in secrets)." - cp "$REPO/.env.example" "$REPO/.env" - chown vscode:vscode "$REPO/.env" 2>/dev/null || true -fi -# Dev needs the Vite HMR server; the compose default is fail-closed preview. -if ! grep -q "^FRONTEND_MODE=" "$REPO/.env" 2>/dev/null; then - echo "FRONTEND_MODE=dev" >> "$REPO/.env" - chown vscode:vscode "$REPO/.env" 2>/dev/null || true -fi echo "--- Waiting for compose services ---" +if [ ! -f "$REPO/.env" ]; then + echo "ERROR: $REPO/.env is missing. Copy .env.example to .env and fill in secrets." >&2 + exit 1 +fi python3 - <<'EOF' import socket import sys @@ -78,13 +36,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.items()): + for host, port in list(pending): s = socket.socket() s.settimeout(2) try: s.connect((host, port)) print(f"OK {host}:{port}", flush=True) - del pending[host] + del pending[(host, port)] except OSError: pass finally: @@ -99,18 +57,13 @@ EOF # entrypoint on every start). echo "--- Syncing canonical flows ---" cd "$REPO/backend" -as_vscode "uv run python scripts/sync_flows.py --sync" || echo "WARN: flow sync failed (check backend/flows and LFX)" - -echo "--- Installing Git Credential Manager ---" -# GCM (Linux) depends on libicu (.NET runtime) — runtime libs only. -apt-get install -y -qq libicu76 2>/dev/null || true -GCM_DEB_URL="https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.9.1/gcm-linux-x64-2.9.1.deb" -GCM_DEB="/tmp/gcm-linux-x64-2.9.1.deb" -curl -fsSL "$GCM_DEB_URL" -o "$GCM_DEB" -dpkg -i "$GCM_DEB" 2>/dev/null || apt-get install -f -y -as_vscode "git config --global credential.helper manager" -rm -f "$GCM_DEB" +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 66225d5..e65f05c 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,12 @@ # Copy to `.env` and fill in values. `.env` is gitignored. -# Generate secrets with: openssl rand -hex 32 -# Validate before deploying: uv run python scripts/check_prod.py --env ../.env -# Dev defaults live in docker-compose.yml (APP_ENV=dev, FORGEJO_USERNAME=jonas). -# Prod overrides via this .env file (e.g. APP_ENV=prod). -# Paths resolve from the repo root in code. +# 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. FORGEJO_TOKEN= -# --- 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 (authoring UI) --- LANGFLOW_URL=http://langflow:7860 LANGFLOW_SUPERUSER=langflow -# Long random values (check_prod.py enforces length). Never leave the -# placeholders: the authoring UI is internet-facing. LANGFLOW_SUPERUSER_PASSWORD= LANGFLOW_SECRET_KEY= # Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope. @@ -28,27 +19,9 @@ LFX_API_KEY=lfx- # --- API self-reference for flow callbacks (must be reachable FROM lfx) --- CALLBACK_BASE_URL=http://api:8000 -# Shared secret for flow callbacks (POST /api/approvals carries it as -# X-Callback-Token). Long random value; empty disables callbacks. -CALLBACK_TOKEN= -# --- frontend mode: unset/preview serves the production bundle --- -# Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it. -# Never set `dev` in prod: the Vite dev server must not face the internet. -# FRONTEND_MODE=dev - -# --- access management (request + approval, invite via email) --- -# Public frontend URL (magic links point here). -FRONTEND_URL=http://localhost:3000 -# Comma-separated emails that become admins (role assignment + bootstrap). -ADMIN_EMAILS= -# Reply-To on outgoing mail; also receives new access-request notifications. -CONTACT_TO= -MAIL_FROM= -SMTP_HOST= -SMTP_PORT=587 -SMTP_USERNAME= -SMTP_PASSWORD= +# --- frontend mode: dev server with HMR (prod .env must set `preview`) --- +FRONTEND_MODE=dev # --- LLM (optional; tests mock the provider without it) --- # LLM_API_KEY= \ No newline at end of file diff --git a/.forgejo/workflows/backend.yml b/.forgejo/workflows/backend.yml index aef58b6..34217c1 100644 --- a/.forgejo/workflows/backend.yml +++ b/.forgejo/workflows/backend.yml @@ -19,6 +19,4 @@ jobs: steps: - uses: actions/checkout@v4 - run: cd backend && uv sync - - run: cd backend && uv run ruff check . - - run: cd backend && uv run ruff format . --check --diff - run: cd backend && uv run pytest diff --git a/.gitignore b/.gitignore index 2e99234..ba92757 100644 --- a/.gitignore +++ b/.gitignore @@ -26,10 +26,6 @@ backend/data/ *.swp *.swo -# Devcontainer -.devcontainer/devcontainer-lock.json -.devcontainer/opencode-data/ - # OS .DS_Store Thumbs.db diff --git a/AGENTS.md b/AGENTS.md index 60a6464..910e1da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,6 @@ Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/ Backend (run from `backend/`): - `uv sync` — install dependencies - `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed) -- `uv run ruff check .` — lint (no findings allowed; only pre-exempt `EXE002`, see `[tool.ruff.lint.ignore]`) -- `uv run ruff format .` — code formatting (run after edits; CI checks with `--check --diff`) - `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored) - `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX - `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent) @@ -34,11 +32,6 @@ profiles, or override files. Containers: `api` (FastAPI :8000, attached), - Prod: own `.env` with secrets + `FRONTEND_MODE=preview`; code updates via `git pull` + restart, image rebuilds only for dependency changes (`docker compose up -d --build`). -- Git in the container uses HTTPS with GCM (`credential.helper=manager`, - installed by `.devcontainer/setup.sh` together with `libicu76` runtime). - Remote URL has no embedded token. First push/pull triggers a device-code - prompt (open URL on host). Rotate GitLab tokens in GitLab UI before - switching credentials. ## Definition of done @@ -69,25 +62,3 @@ report = await sync_from_directory(session, lfx, settings.flows_dir) ## Git When committing: `git add -A && git commit -m ""` (conventional commits: `feat:`, `fix:`, `chore:`, `docs:`). When pushing: `git push` (branch is tracking `origin/main`). - -## Versioning - -- `VERSION` (repo root) is canonical — currently `0.1.0`. -- Backend: `backend/pyproject.toml` version; Frontend: `frontend/package.json` version — bump both together with VERSION on every release. -- SemVer: feat → minor (X.Y+1.0), fix → patch (X.Y.Z+1), breaking → major (X+1.0.0). Tag `vX.Y.Z` at release merge. -- Changelog: every user-visible change gets an `[Unreleased]` entry (Keep a Changelog categories) before commit; at release move it into `[X.Y.Z] - YYYY-MM-DD`. - -## Git workflow - -- New work branches from `main`: `feat/` for features, `fix/` for fixes. -- Push branch, open MR, merge `--no-ff` on green CI, delete branch. -- Never push directly to `main` for features or fixes. - -## Web-dev digest - -- Small batches: PR diff ≤ ~200 lines ideal; trunk-based with short-lived branches (above). -- Tests green before merge (definition of done); `npm run build` green for frontend. -- Docs in same PR as feature (README, AGENTS, CHANGELOG). -- Deprecate in minor, remove in next major; document Removed/Deprecated in CHANGELOG. -- Lockfiles (uv.lock, package-lock.json, compose pins) — don't drift without reason. -- Future implementation ideas → `ROADMAP.md` (authoritative); `CHANGELOG.md` is historical only. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 8eab383..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,49 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] -### Security -- Production ops: `scripts/check_prod.py` validates `.env` secrets and - modes before deploy (with tests); README production checklist - (proxy gates for Langflow, secret rotation, upgrade runbook). -### Security -- API authorization: flow management is admin-only, runs and run/approval - reads require login with strict session-tenant scoping, approval - decisions record the session user, and flow callbacks authenticate with - a shared `X-Callback-Token` (`CALLBACK_TOKEN`). Request-controlled - `tenant_id`, `callback_base_url` and `continuation_flow_id` were - removed (SSRF / cross-tenant / flow-chaining hardening); - continuations now resolve from the admin-authored definition. -- Frontend: `/app/*` routes require login; approver is the session user. -- Edge hardening: interactive API docs only with debug, security headers - (nosniff/frame/Referrer-Policy), 2 MiB body cap, per-IP run throttle, - generic upstream-error responses, magic links never logged. -- Containers run rootless (api/vite as uid 1000, lfx as `lfx` user); - frontend fails closed to preview mode unless `FRONTEND_MODE=dev`. -### Added -- Landing page: status-quo problem block, 5-step process with quote, - portal overview with admin areas, AI platform parts, exception tiers, - quantified benefits, integrations, use-case examples, pilot-program - section, expanded FAQ (8 items incl. KI data usage, price model, - responsibility). -### Changed -- Landing page copy overhauled after 3 reviewer rounds (church-admin - compliance wording: "entwickelt nach DSG-EKD/DSGVO" + AVV/TOMs, - human release responsibility, honest pilot framing instead of - invented testimonials; CTA now "Pilotgespräch vereinbaren"). -- New photo `pilot-meeting.webp` (consultation scene, self-hosted). -- Backend lint + format standards via ruff (pinned dev dependency, - enforced in CI): `uv run ruff check .`, `uv run ruff format`. - -## [0.1.0] - 2026-09-13 -### Added -- Repo scaffold: docker-compose dev/prod, version-pinned images, devcontainer. -- Magic-link auth, access requests, RBAC, frontend access-management UI. -- File-based flow source of truth + sync engine + LFX runtime. -- RJSF dynamic forms, router, Mantine dark-mode design system. -- Conversion landing page, automated screenshots, bell-tower favicon. -- Email-first login, branded multipart HTML magic-link mails. -- lfx level-4 validation on all canonical flows + regression test. diff --git a/Dockerfile.lfx b/Dockerfile.lfx index 657e531..00514b0 100644 --- a/Dockerfile.lfx +++ b/Dockerfile.lfx @@ -8,21 +8,12 @@ ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 # lfx = flow runtime; requests = convenience for custom-component code. -# 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 pip install lfx requests -# Non-root runtime: uploads persist into the mounted volume. -RUN useradd -r -s /bin/false lfx && mkdir -p /flows && chown lfx /flows +RUN mkdir -p /flows VOLUME /flows EXPOSE 6004 -USER lfx # Serve the /flows dir (uploaded flows persist there via --flow-dir). -# 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"] +# Flags verified against `lfx serve --help` (lfx 1.12.0). +CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows", "/flows"] diff --git a/README.md b/README.md index 30fde11..8ca5514 100644 --- a/README.md +++ b/README.md @@ -22,28 +22,8 @@ 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. -- **Access management** — public landing page (German) with an access-request - form (request + approval). Approved users get a passwordless magic-link - invite by email (SMTP via `.env`); sessions are HttpOnly cookies, tenants - come from the session. First admin via `POST /api/auth/admin-bootstrap` - (only on an empty user table, email must be in `ADMIN_EMAILS`). -- **Dynamic forms** — flows declare form fields as typed inputs on a - `CustomInput*` custom component (reusable library in - `backend/components/`, mounted into Langflow via - `LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a - JSON Schema + RJSF uiSchema from the deployed version; the webapp renders - the matching form automatically. Flows without such a node fall back to a - single textarea. See - `backend/components/kirche/README.md` for authoring new form fields. -- **Web UI** — landing page (*Start*), access request (*Zugang*), and the - logged-in webapp: *Workflows* (choose + run with dynamic form), - *Aufgaben* (approvals), *Verwaltung* (admin: access requests, import, - sync, deploy). - -## Future - -Ideas and planned features live in [ROADMAP.md](ROADMAP.md) -(the single authoritative place for future implementation ideas). +- **Web UI** — three tabs: *Vorgänge* (flows and runs), *Freigaben* + (approvals), *Administration* (import, sync, deploy). ## Infrastructure @@ -126,32 +106,15 @@ Flow workflow: design in Langflow → export into `backend/flows/.json` (`--pull`, or UI download) → commit → the watcher deploys it automatically. Deleting a file removes the flow including its runs (git keeps the JSON). -## Production (self-hosted — TLS/reverse proxy required, not included) +## Production (self-hosted, internal use — no TLS/reverse proxy included) 1. Check out the repo on the host, create `.env` from `.env.example` with - real secrets (`openssl rand -hex 32`) and `FRONTEND_MODE=preview`. -2. Validate it: `uv run python scripts/check_prod.py --env ../.env` - (run from `backend/`; blocks on weak secrets or dev modes). -3. `docker compose up -d --build`. -4. Code updates via `git pull` + `docker compose restart api frontend` + real secrets and `FRONTEND_MODE=preview`. +2. `docker compose up -d --build`. +3. Code updates via `git pull` + `docker compose restart api frontend` (the checkout is bind-mounted); image rebuilds only for dependency changes (`docker compose up -d --build`). -Internet exposure checklist (both forwarded ports sit behind your HTTPS -proxy — nothing here substitutes for it): - -- Proxy: force HTTPS (+ HSTS), and put an extra login (basic-auth or - OIDC) in front of Langflow `:7860`. Langflow has a history of - critical unauthenticated RCEs — treat its UI as privileged tooling, - never share flows publicly ("Shareable Playground" = RCE risk). -- Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate - after any incident or staff change; re-run `check_prod.py`. -- Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION` - (`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then - `lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on - `backend/flows/*.json`. Check Langflow security advisories monthly. -- Backups: `backend/data/` (SQLite) and the `langflow-data` volume. - Container updates: registry images (`langflowai/langflow:latest`) auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`. diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index 816100f..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,20 +0,0 @@ -# Roadmap - -Future implementation ideas for kirche-einfach. -Ideas live here until they ship (then a `feat:` commit + `[Unreleased]` -entry in CHANGELOG.md). Keep entries short, ordered by priority. - -This file is the **authoritative place** for future ideas. -`CHANGELOG.md` is historical (what shipped); `AGENTS.md` is rules/architecture. - -## Planned - -(none yet — add ideas here as they emerge) - -## Considered / not started - -- **Member form-filling page** — members fill out forms (attendance, sign-up) via the same input schema used by admins to *run* flows. -- **Approval decision flow** — complete approve/reject-with-reason + member notification for access requests. -- **E2E tests** — Playwright/Cypress smoke tests across key user journeys. -- **CI pipeline** — complete Forgejo workflows (backend pytest, frontend build, compose validation, lint). -- **Audit log** — track who submitted/approved what (church compliance). diff --git a/VERSION b/VERSION deleted file mode 100644 index 6e8bf73..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index b519d04..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,9 +0,0 @@ -# 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 2abad49..7c5cdbb 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,28 +4,17 @@ FROM python:3.12-slim ENV PYTHONUNBUFFERED=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" + PIP_NO_CACHE_DIR=1 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, curl for the opencode -# installer in setup.sh, git for the VS Code source-control view, gosu to -# drop privileges before serving (the server never runs as root). +# openssh-client for the devcontainer post-start hook. RUN apt-get update \ - && apt-get install -y --no-install-recommends sudo openssh-client curl git ca-certificates gosu \ + && 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 \ - && mkdir -p /opt/python /opt/venv \ - && chmod 755 /opt/python /opt/venv + && echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode WORKDIR /srv/kirche-einfach/backend COPY pyproject.toml alembic.ini entrypoint.sh ./ @@ -34,12 +23,9 @@ 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. -# 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 +RUN uv sync && chmod +x entrypoint.sh ENV FLOWS_WATCH_ENABLED=true EXPOSE 8000 -# 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"] +ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py deleted file mode 100644 index 0466d57..0000000 --- a/backend/app/auth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Magic-link authentication + access-request approval (SMTP invite).""" diff --git a/backend/app/auth/api.py b/backend/app/auth/api.py deleted file mode 100644 index 7880082..0000000 --- a/backend/app/auth/api.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Access + auth HTTP API (magic-link login, session cookie).""" - -from typing import Annotated -from uuid import UUID - -from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response -from sqlmodel import Session - -from app.models import User - -from . import mail, service -from .deps import ( - SESSION_COOKIE, - SESSION_MAX_AGE, - get_current_user, - get_db, - require_admin, -) -from .schemas import ( - AccessDecisionRead, - AccessRequestCreate, - AccessRequestRead, - AdminBootstrapRequest, - MagicConsumeRequest, - MagicRequestRead, - MagicRequestRequest, - MeRead, - MessageRead, -) - -access_router = APIRouter(prefix="/api/access", tags=["access"]) -auth_router = APIRouter(prefix="/api/auth", tags=["auth"]) - - -def _read_request(r) -> AccessRequestRead: - return AccessRequestRead( - id=r.id, - name=r.name, - email=r.email, - reason=r.reason, - status=r.status, - created_at=r.created_at, - decided_at=r.decided_at, - decided_by=r.decided_by, - ) - - -def _read_me(user: User) -> MeRead: - return MeRead( - id=user.id, - email=user.email, - name=user.name, - role=user.role, - tenant_id=user.tenant_id, - ) - - -def _set_session_cookie(response: Response, session_id: str) -> None: - # localhost is a secure context, so Secure is safe for local dev too. - response.set_cookie( - SESSION_COOKIE, - session_id, - max_age=SESSION_MAX_AGE, - path="/", - httponly=True, - secure=True, - samesite="lax", - ) - - -@access_router.post("/requests", response_model=AccessRequestRead, status_code=201) -def create_request(data: AccessRequestCreate, session: Annotated[Session, Depends(get_db)]): - try: - request = service.create_access_request(session, data.name, data.email, data.reason) - except service.RateLimitedError as exc: - raise HTTPException(status_code=429, detail=str(exc)) from exc - mail.send_access_notification(str(request.id), request.name, request.email) - return _read_request(request) - - -@access_router.get("/requests", response_model=list[AccessRequestRead]) -def list_requests( - session: Annotated[Session, Depends(get_db)], - admin: Annotated[User, Depends(require_admin)], - status: str | None = Query(default=None), -): - return [_read_request(r) for r in service.list_access_requests(session, status)] - - -@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead) -def approve_request( - request_id: UUID, - session: Annotated[Session, Depends(get_db)], - admin: Annotated[User, Depends(require_admin)], -): - try: - request, _user, token = service.approve_access_request(session, request_id, admin.email) - except service.NotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except service.ConflictError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - # The token travels by mail only — never in an API response. - email_sent = mail.send_magic_link(request.email, mail.magic_link(token)) - return AccessDecisionRead(request=_read_request(request), email_sent=email_sent) - - -@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead) -def reject_request( - request_id: UUID, - session: Annotated[Session, Depends(get_db)], - admin: Annotated[User, Depends(require_admin)], -): - try: - request = service.reject_access_request(session, request_id, admin.email) - except service.NotFoundError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except service.ConflictError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - return _read_request(request) - - -@auth_router.post("/magic/request", response_model=MagicRequestRead) -def request_magic( - data: MagicRequestRequest, - session: Annotated[Session, Depends(get_db)], -): - """Email-first login: allowed emails get a magic link mailed. - - 403 means "not allowed" — the frontend forwards those to /access. - The token travels by mail only, never in the response. - """ - try: - user, token = service.request_magic_link(session, data.email) - except service.ForbiddenError as exc: - raise HTTPException(status_code=403, detail=str(exc)) from exc - except service.RateLimitedError as exc: - raise HTTPException(status_code=429, detail=str(exc)) from exc - email_sent = mail.send_magic_link(user.email, mail.magic_link(token)) - return MagicRequestRead(message="login link sent", email_sent=email_sent) - - -@auth_router.post("/magic/consume", response_model=MeRead) -def consume_magic( - data: MagicConsumeRequest, - response: Response, - session: Annotated[Session, Depends(get_db)], -): - try: - user_session = service.consume_magic_token(session, data.token) - except ( - service.InvalidTokenError, - service.ExpiredTokenError, - service.UsedTokenError, - ) as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - user = service.get_user(session, user_session.user_id) - if user is None: # pragma: no cover - consume already validated the user - raise HTTPException(status_code=400, detail="unknown token") - _set_session_cookie(response, user_session.id) - return _read_me(user) - - -@auth_router.get("/me", response_model=MeRead) -def read_me(user: Annotated[User, Depends(get_current_user)]): - return _read_me(user) - - -@auth_router.post("/logout", response_model=MessageRead) -def logout( - request: Request, - response: Response, - session: Annotated[Session, Depends(get_db)], -): - # Revoke the server-side row when the cookie maps to a live session; - # always clear the client cookie. - session_id = request.cookies.get(SESSION_COOKIE) - if session_id: - service.revoke_session(session, session_id) - response.delete_cookie(SESSION_COOKIE, path="/") - return MessageRead(message="logged out") - - -@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201) -def admin_bootstrap(data: AdminBootstrapRequest, session: Annotated[Session, Depends(get_db)]): - try: - _user, token = service.bootstrap_admin(session, data.email) - except service.ConflictError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - except service.ForbiddenError as exc: - raise HTTPException(status_code=403, detail=str(exc)) from exc - mail.send_magic_link(service.normalize_email(data.email), mail.magic_link(token)) - return MessageRead(message="admin created, magic link sent") diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py deleted file mode 100644 index b090eb9..0000000 --- a/backend/app/auth/deps.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Session-cookie dependencies. Handlers stay thin; DB via get_session.""" - -from __future__ import annotations - -from collections.abc import Iterator -from typing import Annotated - -from fastapi import Depends, HTTPException, Request -from sqlmodel import Session - -from app.db import get_session -from app.models import User - -from . import service - -SESSION_COOKIE = "kirche_session" -SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds()) - - -def get_db() -> Iterator[Session]: - yield from get_session() - - -def get_optional_user( - request: Request, session: Annotated[Session, Depends(get_db)] -) -> User | None: - session_id = request.cookies.get(SESSION_COOKIE) - if not session_id: - return None - record = service.get_valid_session(session, session_id) - if record is None: - return None - return service.get_user(session, record.user_id) - - -def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User: - user = get_optional_user(request, session) - if user is None: - raise HTTPException(status_code=401, detail="not authenticated") - return user - - -def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User: - if user.role != "admin": - raise HTTPException(status_code=403, detail="admin only") - return user - - -def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str: - """Tenant comes from the session; the body/query param is a deprecated - fallback for unauthenticated callers.""" - if user is not None: - return user.tenant_id - return explicit or default diff --git a/backend/app/auth/mail.py b/backend/app/auth/mail.py deleted file mode 100644 index a8cf957..0000000 --- a/backend/app/auth/mail.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Magic-link delivery. SMTP when configured, dev log fallback otherwise. - -Mail design follows the web branding (see frontend/src/theme.ts): -kirchenblau header, serif headline, white card on paper background. -Email-safe by construction: table layout, inline styles only, system -fonts, no external images (many clients block them / track opens). -""" - -from __future__ import annotations - -import logging -import smtplib -from email.message import EmailMessage - -from app.settings import settings - -logger = logging.getLogger(__name__) - -# Brand palette (mirrors frontend/src/theme.ts kirchenblau). -BLUE = "#1d4a79" -BLUE_LIGHT = "#eef4fb" -INK = "#162f4c" -MUTED = "#5b6b7c" - -BLUE = "#1d4a79" -BLUE_LIGHT = "#eef4fb" -INK = "#162f4c" -MUTED = "#5b6b7c" - - -def magic_link(token: str) -> str: - return f"{settings.frontend_url.rstrip('/')}/login?token={token}" - - -def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str: - """Branded HTML mail body (table layout, inline styles, no images).""" - button = "" - if button_url: - button = ( - '' - f'
' - f'{button_label}
' - ) - return f""" - - - - -
- - - - - - - -
- Kirche Einfach -
-

{title}

-

{intro}

- {button} -
-

- {note} -

-
- -""" - - -def _send(message: EmailMessage) -> None: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: - smtp.starttls() - if settings.smtp_username: - smtp.login(settings.smtp_username, settings.smtp_password) - smtp.send_message(message) - - -def _branded_mail( - subject: str, - to_email: str, - headline: str, - intro: str, - button_label: str, - button_url: str, - note: str, -) -> EmailMessage: - """Text + branded HTML alternative; the link is a real button.""" - message = EmailMessage() - message["Subject"] = subject - message["To"] = to_email - message["From"] = settings.mail_from or settings.contact_to or to_email - if settings.contact_to: - message["Reply-To"] = settings.contact_to - message.set_content(f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n") - # Email-safe HTML: tables + inline CSS; some clients strip - - - - - - - diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png deleted file mode 100644 index 188f78b..0000000 Binary files a/frontend/public/icon-192.png and /dev/null differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png deleted file mode 100644 index 1a68be3..0000000 Binary files a/frontend/public/icon-512.png and /dev/null differ diff --git a/frontend/public/photos/SOURCES.md b/frontend/public/photos/SOURCES.md deleted file mode 100644 index d0e9112..0000000 --- a/frontend/public/photos/SOURCES.md +++ /dev/null @@ -1,20 +0,0 @@ -# Landing-page photo sources - -All files in this directory are self-hosted (no hotlinking, no tracking). -Licensed for free use including commercial purposes, no attribution -required (Pexels License). Re-fetch any time: - - docker compose exec api /opt/venv/bin/python \ - /srv/kirche-einfach/frontend/scripts/fetch-photos.py - -Rejected during visual review (do NOT re-add without checking): -US-megachurch conference screens, dark baroque interior, pandemic masks, -Dutch-language documents. - -| File | Motif | Source | -| ---------------------- | ------------------------------ | ------ | -| hero-church.webp | Stone church tower, blue sky | https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/ | -| church-pews.webp | Bright empty pews, checkerboard| https://www.pexels.com/photo/church-pews-7566207/ | -| paperwork-desk.webp | Desk with stamps and forms | https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/ | -| team-laptop.webp | Team collaborating at laptop | https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/ | -| pilot-meeting.webp | Consultation meeting, notes | https://www.pexels.com/photo/two-people-shaking-hands-5668858/ | diff --git a/frontend/public/photos/church-pews.webp b/frontend/public/photos/church-pews.webp deleted file mode 100644 index 6ec96c5..0000000 Binary files a/frontend/public/photos/church-pews.webp and /dev/null differ diff --git a/frontend/public/photos/hero-church.webp b/frontend/public/photos/hero-church.webp deleted file mode 100644 index 34bd964..0000000 Binary files a/frontend/public/photos/hero-church.webp and /dev/null differ diff --git a/frontend/public/photos/paperwork-desk.webp b/frontend/public/photos/paperwork-desk.webp deleted file mode 100644 index 4650cce..0000000 Binary files a/frontend/public/photos/paperwork-desk.webp and /dev/null differ diff --git a/frontend/public/photos/pilot-meeting.webp b/frontend/public/photos/pilot-meeting.webp deleted file mode 100644 index 334a89f..0000000 Binary files a/frontend/public/photos/pilot-meeting.webp and /dev/null differ diff --git a/frontend/public/photos/team-laptop.webp b/frontend/public/photos/team-laptop.webp deleted file mode 100644 index 8811110..0000000 Binary files a/frontend/public/photos/team-laptop.webp and /dev/null differ diff --git a/frontend/public/screenshots/hero-dark.png b/frontend/public/screenshots/hero-dark.png deleted file mode 100644 index 6908231..0000000 Binary files a/frontend/public/screenshots/hero-dark.png and /dev/null differ diff --git a/frontend/public/screenshots/hero-light.png b/frontend/public/screenshots/hero-light.png deleted file mode 100644 index 11ac6df..0000000 Binary files a/frontend/public/screenshots/hero-light.png and /dev/null differ diff --git a/frontend/public/screenshots/workflows-dark.png b/frontend/public/screenshots/workflows-dark.png deleted file mode 100644 index 50369c0..0000000 Binary files a/frontend/public/screenshots/workflows-dark.png and /dev/null differ diff --git a/frontend/public/screenshots/workflows-light.png b/frontend/public/screenshots/workflows-light.png deleted file mode 100644 index 4adcd3e..0000000 Binary files a/frontend/public/screenshots/workflows-light.png and /dev/null differ diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest deleted file mode 100644 index b69a1c0..0000000 --- a/frontend/public/site.webmanifest +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "Kirche Einfach", - "short_name": "Kirche", - "description": "KI-gestützte Verwaltungsplattform für Kirche", - "start_url": "/", - "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#1d4a79", - "icons": [ - { - "src": "/icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any maskable" - } - ] -} diff --git a/frontend/scripts/capture.mjs b/frontend/scripts/capture.mjs deleted file mode 100644 index 5f8d856..0000000 --- a/frontend/scripts/capture.mjs +++ /dev/null @@ -1,66 +0,0 @@ -/* Repeatable landing-page screenshots (re-run after UI changes). - * - * Usage (frontend container, dev server running): - * npm run screenshots # light scheme (default) - * npm run screenshots -- --scheme=dark - * - * Captures real app states into public/screenshots/ (committed, so the - * landing page always shows current UI). Authenticated states are out of - * scope (magic-link login is not scriptable without SMTP). - */ - -import { mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { chromium } from "playwright"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const outDir = join(root, "public", "screenshots"); -const base = process.env.SCREENSHOT_BASE_URL ?? "http://localhost:3000"; -const schemeArg = process.argv.find((a) => a.startsWith("--scheme=")); -const scheme = schemeArg ? schemeArg.split("=")[1] : "light"; - -const shots = [ - { name: "hero", path: "/", fullPage: true }, - { - name: "workflows", - path: "/app/workflows", - fullPage: false, - selectFlow: "intake_form", - }, -]; - -const browser = await chromium.launch(); -try { - mkdirSync(outDir, { recursive: true }); - const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, - colorScheme: scheme === "dark" ? "dark" : "light", - }); - await context.addInitScript( - (value) => { - try { - localStorage.setItem("kirche-color-scheme", value); - } catch { - /* private mode */ - } - }, - scheme === "dark" ? "dark" : "light", - ); - const page = await context.newPage(); - for (const shot of shots) { - await page.goto(base + shot.path, { waitUntil: "networkidle" }); - await page.waitForTimeout(1500); - if (shot.selectFlow) { - // Show the generated RJSF form instead of the default first flow. - await page.locator('[role="combobox"]').first().click(); - await page.getByRole("option", { name: shot.selectFlow }).click(); - await page.waitForTimeout(2000); - } - const file = join(outDir, `${shot.name}-${scheme}.png`); - await page.screenshot({ path: file, fullPage: shot.fullPage }); - console.log(`wrote ${file}`); - } -} finally { - await browser.close(); -} diff --git a/frontend/scripts/fetch-photos.py b/frontend/scripts/fetch-photos.py deleted file mode 100644 index b36dddb..0000000 --- a/frontend/scripts/fetch-photos.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Fetch landing-page photos (one-time + re-runnable). - -Downloads royalty-free originals (see SOURCES.md next to the photos), -resizes to max 1600px wide and stores them as WebP q80 in -frontend/public/photos/ (self-hosted: no hotlinking, no tracking). - -Run in the api container (has network + Pillow): - docker compose exec api /opt/venv/bin/python \\ - /srv/kirche-einfach/frontend/scripts/fetch-photos.py -""" - -from __future__ import annotations - -import sys -import urllib.request -from io import BytesIO -from pathlib import Path - -try: - from PIL import Image -except ImportError: - sys.exit("Pillow required (present in the api image)") - -ROOT = Path(__file__).resolve().parent.parent.parent -OUT = ROOT / "frontend" / "public" / "photos" - - -def pexels(photo_id: int) -> str: - return ( - f"https://images.pexels.com/photos/{photo_id}/" - f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600" - ) - - -PHOTOS = { - # target file -> (direct file URL, source page for SOURCES.md) - "hero-church.webp": ( - pexels(31630127), - "https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/", - ), - "church-pews.webp": ( - pexels(7566207), - "https://www.pexels.com/photo/church-pews-7566207/", - ), - "paperwork-desk.webp": ( - pexels(18687845), - "https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/", - ), - "team-laptop.webp": ( - pexels(7793702), - "https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/", - ), - "pilot-meeting.webp": ( - pexels(5668858), - "https://www.pexels.com/photo/two-people-shaking-hands-5668858/", - ), -} - - -def fetch(url: str) -> bytes: - req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"}) - with urllib.request.urlopen(req, timeout=120) as response: - return response.read() - - -def main() -> None: - OUT.mkdir(parents=True, exist_ok=True) - for filename, (url, _page) in PHOTOS.items(): - raw = fetch(url) - with Image.open(BytesIO(raw)) as img: - img = img.convert("RGB") - if img.width > 1600: - img = img.resize( - (1600, round(img.height * 1600 / img.width)), Image.LANCZOS - ) - target = OUT / Path(filename).with_suffix(".webp").name - img.save(target, "WEBP", quality=80, method=6) - print(f"wrote {target} ({target.stat().st_size // 1024} KB)") - - -if __name__ == "__main__": - main() diff --git a/frontend/scripts/icons.mjs b/frontend/scripts/icons.mjs deleted file mode 100644 index 7377455..0000000 --- a/frontend/scripts/icons.mjs +++ /dev/null @@ -1,44 +0,0 @@ -/* Generate favicon PNGs + manifest icons from favicon.svg (re-runnable). - * - * Usage (frontend container, Chromium already installed for screenshots): - * npm run icons - * - * Renders the SVG at each target size (crisp native rasterization) into - * public/. apple-touch-icon keeps the solid kirchenblau background (iOS - * fills transparency black). The 512 icon doubles as maskable: the glyph - * stays inside the inner 80% safe zone. - */ - -import { readFileSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { chromium } from "playwright"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const svg = readFileSync(join(root, "public", "favicon.svg"), "utf-8"); -const dataUrl = `data:image/svg+xml,${encodeURIComponent(svg)}`; - -const targets = [ - ["favicon-32x32.png", 32], - ["apple-touch-icon.png", 180], - ["icon-192.png", 192], - ["icon-512.png", 512], -]; - -const browser = await chromium.launch(); -try { - const page = await browser.newPage(); - for (const [file, size] of targets) { - await page.setViewportSize({ width: size, height: size }); - await page.setContent( - ``, - ); - await page.locator("img").screenshot({ - path: join(root, "public", file), - omitBackground: true, - }); - console.log(`wrote public/${file}`); - } -} finally { - await browser.close(); -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b32fcf4..a8ebbdb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,161 +1,39 @@ -import { Link, Navigate, Route, Routes, useNavigate } from "react-router-dom"; -import { - Anchor, - AppShell, - Button, - Center, - Container, - Group, - Loader, - Text, - Title, -} from "@mantine/core"; -import type { ReactNode } from "react"; -import { AuthProvider, useAuth } from "./auth"; -import SchemeToggle from "./components/SchemeToggle"; -import StartPage from "./pages/StartPage"; -import AccessPage from "./pages/AccessPage"; -import LoginPage from "./pages/LoginPage"; -import WorkflowsPage from "./pages/WorkflowsPage"; -import TasksPage from "./pages/TasksPage"; -import AdminPage from "./pages/AdminPage"; +import { useState } from "react"; +import AdminTab from "./components/AdminTab"; +import ApprovalsTab from "./components/ApprovalsTab"; +import FlowsTab from "./components/FlowsTab"; -function Nav() { - const { me, logout } = useAuth(); - const navigate = useNavigate(); +type Tab = "flows" | "approvals" | "admin"; - async function onLogout() { - await logout(); - navigate("/"); - } - - return ( - - - Start - - {!me && ( - - Zugang - - )} - {me && ( - <> - - Workflows - - - Aufgaben - - - Verwaltung - - - )} - - {me && ( - <> - - {me.email} - - - - )} - - ); -} - -function RequireAuth({ children }: { children: ReactNode }) { - const { me, loading } = useAuth(); - if (loading) { - return ( -
- -
- ); - } - // Runs, run history and approvals are login-gated server-side; - // anonymous visitors go to the magic-link login. - if (!me) return ; - return <>{children}; -} - -function Shell() { - const { loading } = useAuth(); - - return ( - - - - - Kirche Einfach - -
-
-
-
- - - - KI-gestützte Verwaltungsplattform für Kirche - - {loading ? ( -
- -
- ) : ( - - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - - )} -
-
-
- ); -} +const tabStyle = (active: boolean): React.CSSProperties => ({ + fontWeight: active ? "bold" : "normal", + textDecoration: active ? "underline" : "none", +}); export default function App() { + const [tab, setTab] = useState("flows"); + return ( - - - +
+

Kirche Einfach

+

KI-gestützte Verwaltungsplattform für Kirche

+ + {tab === "flows" && } + {tab === "approvals" && } + {tab === "admin" && } +
); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f0af0f7..763ad9f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -69,33 +69,6 @@ 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) { @@ -106,8 +79,6 @@ 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, }); @@ -143,21 +114,22 @@ export const api = { req(`/api/flows/${flowId}/versions/${versionNo}/deploy`, { method: "POST", }), - runFlow: (flowId: string, input: unknown) => + runFlow: (flowId: string, input: unknown, tenantId: string) => req(`/api/flows/${flowId}/run`, { method: "POST", - // Tenant comes from the session server-side; no tenant box anymore. - body: JSON.stringify({ input }), + body: JSON.stringify({ input, tenant_id: tenantId }), }), - getInputSchema: (flowId: string) => - req(`/api/flows/${flowId}/input-schema`), listRuns: (flowId: string) => req(`/api/flows/${flowId}/runs`), listApprovals: () => req("/api/approvals"), - decide: (taskId: string, decision: "approve" | "reject", note: string) => + decide: ( + taskId: string, + decision: "approve" | "reject", + approver: string, + note: string, + ) => req(`/api/approvals/${taskId}/decision`, { method: "POST", - // Identity comes from the session; the server records the user. - body: JSON.stringify({ decision, note }), + body: JSON.stringify({ decision, approver, note }), }), importFlow: (langflowFlowId: string, name?: string) => req<{ definition: FlowDefinition; version: FlowVersion }>( @@ -176,38 +148,4 @@ 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 }), - }), - requestMagicLink: (email: string) => - req<{ message: string; email_sent: boolean }>("/api/auth/magic/request", { - method: "POST", - body: JSON.stringify({ email }), - }), - logout: () => - req<{ message: string }>("/api/auth/logout", { method: "POST" }), - requestAccess: (name: string, email: string, reason: string) => - req("/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 deleted file mode 100644 index 8a94799..0000000 --- a/frontend/src/auth.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 71daf2e..0000000 --- a/frontend/src/components/AccessQueue.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { - Alert, - Button, - Card, - Group, - Stack, - Text, - Title, -} from "@mantine/core"; -import { - IconAlertCircle, - IconCheck, -} from "@tabler/icons-react"; -import { ApiError, api, type AccessRequest } from "../api"; -import StatusBadge from "./StatusBadge"; - -export default function AccessQueue() { - const [requests, setRequests] = useState([]); - const [error, setError] = useState(""); - const [info, setInfo] = useState(""); - const [busyId, setBusyId] = useState(""); - - const load = useCallback(async () => { - try { - setRequests(await api.listAccessRequests()); - } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - async function decide(id: string, approve: boolean) { - setBusyId(id); - setError(""); - setInfo(""); - try { - const res = await api.decideAccess(id, approve); - setRequests((prev) => - prev.map((r) => (r.id === id ? res.request : r)), - ); - if (approve && !res.email_sent) { - setInfo("Genehmigt — die E-Mail kam nicht an (SMTP prüfen)."); - } - } catch (e) { - setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)); - } finally { - setBusyId(""); - } - } - - const pending = requests.filter((r) => r.status === "pending"); - const done = requests.filter((r) => r.status !== "pending"); - - return ( -
- - Zugangsanfragen - - {error && ( - } mb="md"> - {error} - - )} - {info && ( - } mb="md"> - {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.name} · {r.email} - - - ))} - - )} -
- ); -} diff --git a/frontend/src/components/AdminTab.tsx b/frontend/src/components/AdminTab.tsx index 9c068de..041ff96 100644 --- a/frontend/src/components/AdminTab.tsx +++ b/frontend/src/components/AdminTab.tsx @@ -1,22 +1,4 @@ import { useCallback, useEffect, useState } from "react"; -import { - Alert, - Anchor, - Button, - Card, - Group, - Table, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { - IconAlertCircle, - IconCheck, - IconExternalLink, - IconRefresh, - IconUpload, -} from "@tabler/icons-react"; import { ApiError, LANGFLOW_URL, @@ -24,7 +6,6 @@ import { type FlowDefinition, type FlowVersion, } from "../api"; -import StatusBadge from "./StatusBadge"; export default function AdminTab() { const [flows, setFlows] = useState([]); @@ -105,110 +86,61 @@ export default function AdminTab() { return (
- - Flows & Versionen - - {error && ( - } mb="md"> - {error} - - )} - {info && ( - } mb="md"> - {info} - - )} - - - Flows werden visuell in Langflow entworfen:{" "} - - Langflow öffnen - - - - setLangflowId(e.target.value)} - style={{ flex: 1 }} - styles={{ input: { fontFamily: "monospace" } }} - /> - setImportName(e.target.value)} - /> - - - - - - - - - {flows.length === 0 ? ( - Keine Flows vorhanden. - ) : ( - - - - Flow - Version - Status - Autor - - - - - {flows.map((f) => - (versions[f.id] ?? []).map((v, i) => ( - - - {i === 0 && ( - <> - {f.name}{" "} - - {f.source_path ? `(${f.source_path})` : "(API)"} - - - )} - - v{v.version_no} - - {v.deployed_at ? ( - - ) : ( - - )} - - {v.author || "–"} - - - - - )), - )} - -
- )} +

Administration

+ {error &&

{error}

} + {info &&

{info}

} +

+ Flows werden visuell in Langflow entworfen:{" "} + + Langflow öffnen + +

+

Import aus Langflow

+
+ setLangflowId(e.target.value)} + placeholder="Langflow Flow-ID (UUID)" + style={{ flex: 1, fontFamily: "monospace" }} + /> + setImportName(e.target.value)} + placeholder="Name (optional)" + /> + +
+

Flows + Versionen

+
+ +
+ {flows.length === 0 &&

Keine Flows vorhanden.

} +
    + {flows.map((f) => ( +
  • + {f.name}{" "} + + {f.source_path ? `(${f.source_path})` : "(API)"} + +
      + {(versions[f.id] ?? []).map((v) => ( +
    • + v{v.version_no} ·{" "} + {v.deployed_at ? `deployed (${v.lfx_flow_id})` : "nicht deployed"} ·{" "} + {v.author || "–"}{" "} + +
    • + ))} +
    +
  • + ))} +
); } diff --git a/frontend/src/components/ApprovalsTab.tsx b/frontend/src/components/ApprovalsTab.tsx index 0412f32..8c9c57e 100644 --- a/frontend/src/components/ApprovalsTab.tsx +++ b/frontend/src/components/ApprovalsTab.tsx @@ -1,23 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import { - Alert, - Button, - Card, - Code, - Group, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react"; import { ApiError, api, type ApprovalTask } from "../api"; -import { useAuth } from "../auth"; -import StatusBadge from "./StatusBadge"; export default function ApprovalsTab() { - const { me } = useAuth(); const [tasks, setTasks] = useState([]); + const [approver, setApprover] = useState(""); const [note, setNote] = useState(""); const [error, setError] = useState(""); const [busyId, setBusyId] = useState(""); @@ -38,7 +24,7 @@ export default function ApprovalsTab() { setBusyId(taskId); setError(""); try { - const result = await api.decide(taskId, decision, note); + const result = await api.decide(taskId, decision, approver, note); setTasks((prev) => prev.map((t) => (t.id === taskId ? result.task : t)), ); @@ -54,88 +40,56 @@ export default function ApprovalsTab() { return (
- - Freigaben - - {error && ( - } mb="md"> - {error} - - )} - - Angemeldet als {me?.email ?? "–"} — Entscheidungen werden diesem - Konto zugeordnet. - - - Freigaben + {error &&

{error}

} +
+ setApprover(e.target.value)} + placeholder="Name (Bearbeiter)" + /> + setNote(e.target.value)} + placeholder="Notiz" style={{ flex: 1 }} /> - - - - Offen ({open.length}) - - {open.length === 0 && ( - - Keine offenen Freigaben. - - )} - + +
+

Offen ({open.length})

+ {open.length === 0 &&

Keine offenen Freigaben.

} +
    {open.map((t) => ( - - - - {t.title || t.id} - - {t.tenant_id} - - - - {JSON.stringify(t.payload)} - - - - - - +
  • + {t.title || t.id} · {t.tenant_id} +
    + {JSON.stringify(t.payload)} +
    + {" "} + +
  • ))} - - - Entschieden ({done.length}) - - {done.map((t) => ( - - - - {t.title || t.id} · {t.approver ?? "–"} +
+

Entschieden ({done.length})

+
    + {done.map((t) => ( +
  • + {t.status} · {t.title || t.id} ·{" "} + {t.approver ?? "–"} {t.decision_note ? ` · „${t.decision_note}“` : ""} - - - ))} +
  • + ))} +
); } diff --git a/frontend/src/components/FlowsTab.tsx b/frontend/src/components/FlowsTab.tsx new file mode 100644 index 0000000..98cfdbe --- /dev/null +++ b/frontend/src/components/FlowsTab.tsx @@ -0,0 +1,105 @@ +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 }} + /> + +
+