diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 41ed32c..2d14211 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,14 +11,23 @@ }, "mounts": [ "source=${localEnv:USERPROFILE}/.ssh,target=/mnt/host-ssh,type=bind,readonly", - "source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind" + "source=kirche-opencode-share,target=/home/vscode/.local/share/opencode,type=volume", + "source=kirche-opencode-config,target=/home/vscode/.config/opencode,type=volume" ], "features": { - "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + // moby:false — python:3.12-slim is Debian trixie now, whose repos have + // no moby-cli (feature default). false installs the upstream Docker CE + // CLI instead, which supports trixie. + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "moby": false + } }, "overrideCommand": false, - "postCreateCommand": "sudo -E bash .devcontainer/setup.sh", - "postStartCommand": "bash .devcontainer/postStart.sh", + // Absolute paths: lifecycle commands run in the image WORKDIR + // (/srv/kirche-einfach/backend), not the workspace folder — relative + // .devcontainer/... paths fail with "No such file or directory" (exit 127). + "postCreateCommand": "sudo -E bash /srv/kirche-einfach/.devcontainer/setup.sh", + "postStartCommand": "bash /srv/kirche-einfach/.devcontainer/postStart.sh", "customizations": { "vscode": { "extensions": [ @@ -29,7 +38,7 @@ "esbenp.prettier-vscode" ], "settings": { - "python.defaultInterpreterPath": "/srv/kirche-einfach/backend/.venv/bin/python" + "python.defaultInterpreterPath": "/opt/venv/bin/python" } } } diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index d002adf..9e9b0b3 100644 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -2,31 +2,73 @@ # Dev-only setup (runs via postCreateCommand in the attached `api` container). # Production needs none of this: images carry their dependencies and the # api entrypoint migrates on every start. -set -e +set -eo pipefail REPO=/srv/kirche-einfach +# Run a command as vscode with the current environment intact. +# (`su vscode -c` resets the environment, which used to drop +# UV_PROJECT_ENVIRONMENT/UV_PYTHON_INSTALL_DIR plus every compose-provided +# variable — uv then created a project-local backend/.venv in the bind-mount +# instead of using /opt/venv, and sync_flows.py ran without its settings.) +# -H repoints HOME to /home/vscode: without it HOME stays /root (from the +# outer sudo) and uv/pip caches fail with Permission denied. +as_vscode() { + sudo -H -u vscode -E bash -c "$*" +} + +# Venv lives outside the bind-mount (see backend/Dockerfile). +export UV_PYTHON_INSTALL_DIR=/opt/python UV_PROJECT_ENVIRONMENT=/opt/venv + echo "=== Setting up kirche-einfach ===" -# Backend deps into the venv volume (setup runs via sudo; handed to vscode below). +# Writable dirs first: this script runs via sudo as root, but the editor and +# most commands run as vscode. (A root-created backend/data/ or venv used to +# break the DB and the interpreter for vscode.) +mkdir -p "$REPO/backend/data" \ + "$REPO/frontend/node_modules" \ + /opt/python /opt/venv \ + /home/vscode/.cache \ + /home/vscode/.local/share/uv \ + /home/vscode/.local/share/opencode \ + /home/vscode/.config/opencode +chown -R vscode:vscode "$REPO/backend/data" \ + "$REPO/frontend/node_modules" \ + /opt/python /opt/venv \ + /home/vscode/.cache \ + /home/vscode/.local/share/uv \ + /home/vscode/.local/share/opencode \ + /home/vscode/.config/opencode 2>/dev/null || true + +# Backend deps into /opt/venv, as vscode (a root-owned venv is unusable for +# the editor; uv resolves at setup time, no lockfile is committed). echo "--- Installing Python dependencies ---" cd "$REPO/backend" -uv sync +as_vscode "uv sync" # opencode CLI (dev tooling; intentionally not baked into the image). if [ ! -x /home/vscode/.opencode/bin/opencode ]; then echo "--- Installing opencode ---" - su vscode -c "curl -fsSL https://opencode.ai/install | bash" + as_vscode "curl -fsSL https://opencode.ai/install | bash" ln -sf /home/vscode/.opencode/bin/opencode /usr/local/bin/opencode fi # Compose services (langflow, lfx) must be up; fail loudly otherwise. # (api/frontend are starting alongside this script.) -echo "--- Waiting for compose services ---" +# Fresh clones have no `.env` (gitignored) and compose `env_file` is +# optional, so provision one from the example instead of failing setup. +# Fill in real secrets afterwards; empty SMTP_* means the API logs mail. if [ ! -f "$REPO/.env" ]; then - echo "ERROR: $REPO/.env is missing. Copy .env.example to .env and fill in secrets." >&2 - exit 1 + echo "WARN: $REPO/.env missing — copying .env.example (fill in secrets)." + cp "$REPO/.env.example" "$REPO/.env" + chown vscode:vscode "$REPO/.env" 2>/dev/null || true fi +# Dev needs the Vite HMR server; the compose default is fail-closed preview. +if ! grep -q "^FRONTEND_MODE=" "$REPO/.env" 2>/dev/null; then + echo "FRONTEND_MODE=dev" >> "$REPO/.env" + chown vscode:vscode "$REPO/.env" 2>/dev/null || true +fi +echo "--- Waiting for compose services ---" python3 - <<'EOF' import socket import sys @@ -36,13 +78,13 @@ targets = [("langflow", 7860), ("lfx", 6004)] deadline = time.time() + 180 pending = dict(targets) while pending and time.time() < deadline: - for host, port in list(pending): + for host, port in list(pending.items()): s = socket.socket() s.settimeout(2) try: s.connect((host, port)) print(f"OK {host}:{port}", flush=True) - del pending[(host, port)] + del pending[host] except OSError: pass finally: @@ -57,13 +99,18 @@ EOF # entrypoint on every start). echo "--- Syncing canonical flows ---" cd "$REPO/backend" -uv run python scripts/sync_flows.py --sync || echo "WARN: flow sync failed (check backend/flows and LFX)" +as_vscode "uv run python scripts/sync_flows.py --sync" || echo "WARN: flow sync failed (check backend/flows and LFX)" + +echo "--- Installing Git Credential Manager ---" +# GCM (Linux) depends on libicu (.NET runtime) — runtime libs only. +apt-get install -y -qq libicu76 2>/dev/null || true +GCM_DEB_URL="https://github.com/git-ecosystem/git-credential-manager/releases/download/v2.9.1/gcm-linux-x64-2.9.1.deb" +GCM_DEB="/tmp/gcm-linux-x64-2.9.1.deb" +curl -fsSL "$GCM_DEB_URL" -o "$GCM_DEB" +dpkg -i "$GCM_DEB" 2>/dev/null || apt-get install -f -y +as_vscode "git config --global credential.helper manager" +rm -f "$GCM_DEB" echo "" echo "=== Setup complete ===" echo "Forgejo username: $FORGEJO_USERNAME" - -# Hand dirs back to vscode (this script runs via sudo; uv creates caches as root) -chown -R vscode:vscode "$REPO/backend/.venv" \ - "$REPO/frontend/node_modules" \ - /home/vscode/.cache 2>/dev/null || true diff --git a/.env.example b/.env.example index e65f05c..66225d5 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,21 @@ # Copy to `.env` and fill in values. `.env` is gitignored. -# It holds only ubiquitous variables (identical value in dev and prod). -# Dev-only variables travel via devcontainer.json `containerEnv` -# (e.g. APP_ENV=dev); paths resolve from the repo root in code. +# Generate secrets with: openssl rand -hex 32 +# Validate before deploying: uv run python scripts/check_prod.py --env ../.env +# Dev defaults live in docker-compose.yml (APP_ENV=dev, FORGEJO_USERNAME=jonas). +# Prod overrides via this .env file (e.g. APP_ENV=prod). +# Paths resolve from the repo root in code. FORGEJO_TOKEN= -# --- langflow (authoring UI) --- +# --- langflow (authoring UI) + lfx (flow runtime): pinned in lockstep --- +# LFX contract: major.minor of Langflow and LFX must match (patches are +# independent). Bump deliberately on both sides together (compose image pin +# below + Dockerfile.lfx ARG + backend dev dep `lfx~=`), then re-check +# backend/flows/*.json with `lfx upgrade --upgrade-flow=safe`. +LANGFLOW_VERSION=1.12.0 LANGFLOW_URL=http://langflow:7860 LANGFLOW_SUPERUSER=langflow +# Long random values (check_prod.py enforces length). Never leave the +# placeholders: the authoring UI is internet-facing. LANGFLOW_SUPERUSER_PASSWORD= LANGFLOW_SECRET_KEY= # Single static key; validated against env (LANGFLOW_API_KEY_SOURCE=env), superuser scope. @@ -19,9 +28,27 @@ LFX_API_KEY=lfx- # --- API self-reference for flow callbacks (must be reachable FROM lfx) --- CALLBACK_BASE_URL=http://api:8000 +# Shared secret for flow callbacks (POST /api/approvals carries it as +# X-Callback-Token). Long random value; empty disables callbacks. +CALLBACK_TOKEN= -# --- frontend mode: dev server with HMR (prod .env must set `preview`) --- -FRONTEND_MODE=dev +# --- frontend mode: unset/preview serves the production bundle --- +# Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it. +# Never set `dev` in prod: the Vite dev server must not face the internet. +# FRONTEND_MODE=dev + +# --- access management (request + approval, invite via email) --- +# Public frontend URL (magic links point here). +FRONTEND_URL=http://localhost:3000 +# Comma-separated emails that become admins (role assignment + bootstrap). +ADMIN_EMAILS= +# Reply-To on outgoing mail; also receives new access-request notifications. +CONTACT_TO= +MAIL_FROM= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USERNAME= +SMTP_PASSWORD= # --- LLM (optional; tests mock the provider without it) --- # LLM_API_KEY= \ No newline at end of file diff --git a/.forgejo/workflows/backend.yml b/.forgejo/workflows/backend.yml index 34217c1..aef58b6 100644 --- a/.forgejo/workflows/backend.yml +++ b/.forgejo/workflows/backend.yml @@ -19,4 +19,6 @@ jobs: steps: - uses: actions/checkout@v4 - run: cd backend && uv sync + - run: cd backend && uv run ruff check . + - run: cd backend && uv run ruff format . --check --diff - run: cd backend && uv run pytest 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/AGENTS.md b/AGENTS.md index 910e1da..60a6464 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,8 @@ Kirche Einfach — AI-assisted church administration. FastAPI backend (`backend/ Backend (run from `backend/`): - `uv sync` — install dependencies - `uv run pytest` — full test suite (throwaway SQLite file, no external DB needed) +- `uv run ruff check .` — lint (no findings allowed; only pre-exempt `EXE002`, see `[tool.ruff.lint.ignore]`) +- `uv run ruff format .` — code formatting (run after edits; CI checks with `--check --diff`) - `uv run alembic upgrade head` — migrate the dev database (`backend/data/`, gitignored) - `uv run python scripts/sync_flows.py --sync` — reconcile `backend/flows/` into DB + LFX - `uv run uvicorn app.main:app --port 8000` — run the API (paths resolve from repo root, CWD-independent) @@ -32,6 +34,11 @@ profiles, or override files. Containers: `api` (FastAPI :8000, attached), - Prod: own `.env` with secrets + `FRONTEND_MODE=preview`; code updates via `git pull` + restart, image rebuilds only for dependency changes (`docker compose up -d --build`). +- Git in the container uses HTTPS with GCM (`credential.helper=manager`, + installed by `.devcontainer/setup.sh` together with `libicu76` runtime). + Remote URL has no embedded token. First push/pull triggers a device-code + prompt (open URL on host). Rotate GitLab tokens in GitLab UI before + switching credentials. ## Definition of done @@ -62,3 +69,25 @@ report = await sync_from_directory(session, lfx, settings.flows_dir) ## Git When committing: `git add -A && git commit -m ""` (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 new file mode 100644 index 0000000..8eab383 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +All notable changes to this project will be documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Security +- Production ops: `scripts/check_prod.py` validates `.env` secrets and + modes before deploy (with tests); README production checklist + (proxy gates for Langflow, secret rotation, upgrade runbook). +### Security +- API authorization: flow management is admin-only, runs and run/approval + reads require login with strict session-tenant scoping, approval + decisions record the session user, and flow callbacks authenticate with + a shared `X-Callback-Token` (`CALLBACK_TOKEN`). Request-controlled + `tenant_id`, `callback_base_url` and `continuation_flow_id` were + removed (SSRF / cross-tenant / flow-chaining hardening); + continuations now resolve from the admin-authored definition. +- Frontend: `/app/*` routes require login; approver is the session user. +- Edge hardening: interactive API docs only with debug, security headers + (nosniff/frame/Referrer-Policy), 2 MiB body cap, per-IP run throttle, + generic upstream-error responses, magic links never logged. +- Containers run rootless (api/vite as uid 1000, lfx as `lfx` user); + frontend fails closed to preview mode unless `FRONTEND_MODE=dev`. +### Added +- Landing page: status-quo problem block, 5-step process with quote, + portal overview with admin areas, AI platform parts, exception tiers, + quantified benefits, integrations, use-case examples, pilot-program + section, expanded FAQ (8 items incl. KI data usage, price model, + responsibility). +### Changed +- Landing page copy overhauled after 3 reviewer rounds (church-admin + compliance wording: "entwickelt nach DSG-EKD/DSGVO" + AVV/TOMs, + human release responsibility, honest pilot framing instead of + invented testimonials; CTA now "Pilotgespräch vereinbaren"). +- New photo `pilot-meeting.webp` (consultation scene, self-hosted). +- Backend lint + format standards via ruff (pinned dev dependency, + enforced in CI): `uv run ruff check .`, `uv run ruff format`. + +## [0.1.0] - 2026-09-13 +### Added +- Repo scaffold: docker-compose dev/prod, version-pinned images, devcontainer. +- Magic-link auth, access requests, RBAC, frontend access-management UI. +- File-based flow source of truth + sync engine + LFX runtime. +- RJSF dynamic forms, router, Mantine dark-mode design system. +- Conversion landing page, automated screenshots, bell-tower favicon. +- Email-first login, branded multipart HTML magic-link mails. +- lfx level-4 validation on all canonical flows + regression test. diff --git a/Dockerfile.lfx b/Dockerfile.lfx index 00514b0..657e531 100644 --- a/Dockerfile.lfx +++ b/Dockerfile.lfx @@ -8,12 +8,21 @@ ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 # lfx = flow runtime; requests = convenience for custom-component code. -RUN pip install lfx requests +# Pinned to the Langflow minor (contract: major.minor must match Langflow; +# patches independent). Bump together with LANGFLOW_VERSION (.env.example). +ARG LFX_VERSION=1.12.0 +RUN pip install "lfx~=${LFX_VERSION}" requests -RUN mkdir -p /flows +# Non-root runtime: uploads persist into the mounted volume. +RUN useradd -r -s /bin/false lfx && mkdir -p /flows && chown lfx /flows VOLUME /flows EXPOSE 6004 +USER lfx # Serve the /flows dir (uploaded flows persist there via --flow-dir). -# Flags verified against `lfx serve --help` (lfx 1.12.0). -CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows", "/flows"] +# No positional path: with one, lfx>=1.12.1 exits when the dir holds no +# .json yet ("No .json files found"), restart-looping the container on +# first boot. --flow-dir alone starts clean (health 200); the API sync +# deploys backend/flows/*.json right after boot. +# Flags verified against `lfx serve --help` (lfx 1.12.x). +CMD ["lfx", "serve", "--host", "0.0.0.0", "--port", "6004", "--flow-dir", "/flows"] diff --git a/README.md b/README.md index 8ca5514..30fde11 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,28 @@ needs judgment. Every step is documented and traceable. deploy to the lightweight LFX runtime and can run synchronously or streamed. - **Approvals** — a flow can pause and ask a human for a decision. Approving can automatically start a follow-up ("continuation") flow. -- **Web UI** — three tabs: *Vorgänge* (flows and runs), *Freigaben* - (approvals), *Administration* (import, sync, deploy). +- **Access management** — public landing page (German) with an access-request + form (request + approval). Approved users get a passwordless magic-link + invite by email (SMTP via `.env`); sessions are HttpOnly cookies, tenants + come from the session. First admin via `POST /api/auth/admin-bootstrap` + (only on an empty user table, email must be in `ADMIN_EMAILS`). +- **Dynamic forms** — flows declare form fields as typed inputs on a + `CustomInput*` custom component (reusable library in + `backend/components/`, mounted into Langflow via + `LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a + JSON Schema + RJSF uiSchema from the deployed version; the webapp renders + the matching form automatically. Flows without such a node fall back to a + single textarea. See + `backend/components/kirche/README.md` for authoring new form fields. +- **Web UI** — landing page (*Start*), access request (*Zugang*), and the + logged-in webapp: *Workflows* (choose + run with dynamic form), + *Aufgaben* (approvals), *Verwaltung* (admin: access requests, import, + sync, deploy). + +## Future + +Ideas and planned features live in [ROADMAP.md](ROADMAP.md) +(the single authoritative place for future implementation ideas). ## Infrastructure @@ -106,15 +126,32 @@ Flow workflow: design in Langflow → export into `backend/flows/.json` (`--pull`, or UI download) → commit → the watcher deploys it automatically. Deleting a file removes the flow including its runs (git keeps the JSON). -## Production (self-hosted, internal use — no TLS/reverse proxy included) +## Production (self-hosted — TLS/reverse proxy required, not included) 1. Check out the repo on the host, create `.env` from `.env.example` with - real secrets and `FRONTEND_MODE=preview`. -2. `docker compose up -d --build`. -3. Code updates via `git pull` + `docker compose restart api frontend` + real secrets (`openssl rand -hex 32`) and `FRONTEND_MODE=preview`. +2. Validate it: `uv run python scripts/check_prod.py --env ../.env` + (run from `backend/`; blocks on weak secrets or dev modes). +3. `docker compose up -d --build`. +4. Code updates via `git pull` + `docker compose restart api frontend` (the checkout is bind-mounted); image rebuilds only for dependency changes (`docker compose up -d --build`). +Internet exposure checklist (both forwarded ports sit behind your HTTPS +proxy — nothing here substitutes for it): + +- Proxy: force HTTPS (+ HSTS), and put an extra login (basic-auth or + OIDC) in front of Langflow `:7860`. Langflow has a history of + critical unauthenticated RCEs — treat its UI as privileged tooling, + never share flows publicly ("Shareable Playground" = RCE risk). +- Secrets: all `*_KEY`/`*_TOKEN`/`*_PASSWORD` long and random; rotate + after any incident or staff change; re-run `check_prod.py`. +- Langflow/LFX upgrades (pinned in lockstep): bump `LANGFLOW_VERSION` + (`.env`), `Dockerfile.lfx` ARG, and backend `lfx~=` together, then + `lfx upgrade --upgrade-flow=safe` + `lfx validate --level 4` on + `backend/flows/*.json`. Check Langflow security advisories monthly. +- Backups: `backend/data/` (SQLite) and the `langflow-data` volume. + Container updates: registry images (`langflowai/langflow:latest`) auto-update nightly via Watchtower (`nickfedor/watchtower`). Locally built images (`api`, `lfx`, `frontend`) update with `docker compose build --pull`. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..816100f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,20 @@ +# Roadmap + +Future implementation ideas for kirche-einfach. +Ideas live here until they ship (then a `feat:` commit + `[Unreleased]` +entry in CHANGELOG.md). Keep entries short, ordered by priority. + +This file is the **authoritative place** for future ideas. +`CHANGELOG.md` is historical (what shipped); `AGENTS.md` is rules/architecture. + +## Planned + +(none yet — add ideas here as they emerge) + +## Considered / not started + +- **Member form-filling page** — members fill out forms (attendance, sign-up) via the same input schema used by admins to *run* flows. +- **Approval decision flow** — complete approve/reject-with-reason + member notification for access requests. +- **E2E tests** — Playwright/Cypress smoke tests across key user journeys. +- **CI pipeline** — complete Forgejo workflows (backend pytest, frontend build, compose validation, lint). +- **Audit log** — track who submitted/approved what (church compliance). diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 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 7c5cdbb..2abad49 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,17 +4,28 @@ FROM python:3.12-slim ENV PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 + PIP_NO_CACHE_DIR=1 \ + # Venv + managed Python live outside the repo bind-mount: a venv under + # backend/ used to be shadowed by a named volume (empty on first start) + # and backed by a root-owned interpreter vscode could not execute. + # /opt is image-local, world-readable, identical in dev and prod. + UV_PYTHON_INSTALL_DIR=/opt/python \ + UV_PROJECT_ENVIRONMENT=/opt/venv +ENV PATH="/opt/venv/bin:$PATH" COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv # Foundational, inert in prod: non-root user for the editor, sudo for setup, -# openssh-client for the devcontainer post-start hook. +# openssh-client for the devcontainer post-start hook, curl for the opencode +# installer in setup.sh, git for the VS Code source-control view, gosu to +# drop privileges before serving (the server never runs as root). RUN apt-get update \ - && apt-get install -y --no-install-recommends sudo openssh-client \ + && apt-get install -y --no-install-recommends sudo openssh-client curl git ca-certificates gosu \ && rm -rf /var/lib/apt/lists/* \ && useradd -m -s /bin/bash vscode \ - && echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode + && echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode \ + && mkdir -p /opt/python /opt/venv \ + && chmod 755 /opt/python /opt/venv WORKDIR /srv/kirche-einfach/backend COPY pyproject.toml alembic.ini entrypoint.sh ./ @@ -23,9 +34,12 @@ COPY migrations ./migrations COPY scripts ./scripts # No lockfile is committed; dependencies resolve at build time. # Full groups (incl. dev): pytest and friends must work in dev with zero steps. -RUN uv sync && chmod +x entrypoint.sh +# a+rX: the image builds as root, but the attached devcontainer runs as vscode. +RUN uv sync && chmod +x entrypoint.sh && chmod -R a+rX /opt/python /opt/venv ENV FLOWS_WATCH_ENABLED=true EXPOSE 8000 -ENTRYPOINT ["./entrypoint.sh"] +# Explicit interpreter: the repo bind-mount shadows the image at runtime and +# git tracks entrypoint.sh as 100644, so the exec bit cannot be relied on. +ENTRYPOINT ["/bin/sh", "./entrypoint.sh"] 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..7880082 --- /dev/null +++ b/backend/app/auth/api.py @@ -0,0 +1,192 @@ +"""Access + auth HTTP API (magic-link login, session cookie).""" + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from sqlmodel import Session + +from app.models import User + +from . import mail, service +from .deps import ( + SESSION_COOKIE, + SESSION_MAX_AGE, + get_current_user, + get_db, + require_admin, +) +from .schemas import ( + AccessDecisionRead, + AccessRequestCreate, + AccessRequestRead, + AdminBootstrapRequest, + MagicConsumeRequest, + MagicRequestRead, + MagicRequestRequest, + MeRead, + MessageRead, +) + +access_router = APIRouter(prefix="/api/access", tags=["access"]) +auth_router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def _read_request(r) -> AccessRequestRead: + return AccessRequestRead( + id=r.id, + name=r.name, + email=r.email, + reason=r.reason, + status=r.status, + created_at=r.created_at, + decided_at=r.decided_at, + decided_by=r.decided_by, + ) + + +def _read_me(user: User) -> MeRead: + return MeRead( + id=user.id, + email=user.email, + name=user.name, + role=user.role, + tenant_id=user.tenant_id, + ) + + +def _set_session_cookie(response: Response, session_id: str) -> None: + # localhost is a secure context, so Secure is safe for local dev too. + response.set_cookie( + SESSION_COOKIE, + session_id, + max_age=SESSION_MAX_AGE, + path="/", + httponly=True, + secure=True, + samesite="lax", + ) + + +@access_router.post("/requests", response_model=AccessRequestRead, status_code=201) +def create_request(data: AccessRequestCreate, session: Annotated[Session, Depends(get_db)]): + try: + request = service.create_access_request(session, data.name, data.email, data.reason) + except service.RateLimitedError as exc: + raise HTTPException(status_code=429, detail=str(exc)) from exc + mail.send_access_notification(str(request.id), request.name, request.email) + return _read_request(request) + + +@access_router.get("/requests", response_model=list[AccessRequestRead]) +def list_requests( + session: Annotated[Session, Depends(get_db)], + admin: Annotated[User, Depends(require_admin)], + status: str | None = Query(default=None), +): + return [_read_request(r) for r in service.list_access_requests(session, status)] + + +@access_router.post("/requests/{request_id}/approve", response_model=AccessDecisionRead) +def approve_request( + request_id: UUID, + session: Annotated[Session, Depends(get_db)], + admin: Annotated[User, Depends(require_admin)], +): + try: + request, _user, token = service.approve_access_request(session, request_id, admin.email) + except service.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except service.ConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + # The token travels by mail only — never in an API response. + email_sent = mail.send_magic_link(request.email, mail.magic_link(token)) + return AccessDecisionRead(request=_read_request(request), email_sent=email_sent) + + +@access_router.post("/requests/{request_id}/reject", response_model=AccessRequestRead) +def reject_request( + request_id: UUID, + session: Annotated[Session, Depends(get_db)], + admin: Annotated[User, Depends(require_admin)], +): + try: + request = service.reject_access_request(session, request_id, admin.email) + except service.NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except service.ConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return _read_request(request) + + +@auth_router.post("/magic/request", response_model=MagicRequestRead) +def request_magic( + data: MagicRequestRequest, + session: Annotated[Session, Depends(get_db)], +): + """Email-first login: allowed emails get a magic link mailed. + + 403 means "not allowed" — the frontend forwards those to /access. + The token travels by mail only, never in the response. + """ + try: + user, token = service.request_magic_link(session, data.email) + except service.ForbiddenError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except service.RateLimitedError as exc: + raise HTTPException(status_code=429, detail=str(exc)) from exc + email_sent = mail.send_magic_link(user.email, mail.magic_link(token)) + return MagicRequestRead(message="login link sent", email_sent=email_sent) + + +@auth_router.post("/magic/consume", response_model=MeRead) +def consume_magic( + data: MagicConsumeRequest, + response: Response, + session: Annotated[Session, Depends(get_db)], +): + try: + user_session = service.consume_magic_token(session, data.token) + except ( + service.InvalidTokenError, + service.ExpiredTokenError, + service.UsedTokenError, + ) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + user = service.get_user(session, user_session.user_id) + if user is None: # pragma: no cover - consume already validated the user + raise HTTPException(status_code=400, detail="unknown token") + _set_session_cookie(response, user_session.id) + return _read_me(user) + + +@auth_router.get("/me", response_model=MeRead) +def read_me(user: Annotated[User, Depends(get_current_user)]): + return _read_me(user) + + +@auth_router.post("/logout", response_model=MessageRead) +def logout( + request: Request, + response: Response, + session: Annotated[Session, Depends(get_db)], +): + # Revoke the server-side row when the cookie maps to a live session; + # always clear the client cookie. + session_id = request.cookies.get(SESSION_COOKIE) + if session_id: + service.revoke_session(session, session_id) + response.delete_cookie(SESSION_COOKIE, path="/") + return MessageRead(message="logged out") + + +@auth_router.post("/admin-bootstrap", response_model=MessageRead, status_code=201) +def admin_bootstrap(data: AdminBootstrapRequest, session: Annotated[Session, Depends(get_db)]): + try: + _user, token = service.bootstrap_admin(session, data.email) + except service.ConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except service.ForbiddenError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + mail.send_magic_link(service.normalize_email(data.email), mail.magic_link(token)) + return MessageRead(message="admin created, magic link sent") diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py new file mode 100644 index 0000000..b090eb9 --- /dev/null +++ b/backend/app/auth/deps.py @@ -0,0 +1,54 @@ +"""Session-cookie dependencies. Handlers stay thin; DB via get_session.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Annotated + +from fastapi import Depends, HTTPException, Request +from sqlmodel import Session + +from app.db import get_session +from app.models import User + +from . import service + +SESSION_COOKIE = "kirche_session" +SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds()) + + +def get_db() -> Iterator[Session]: + yield from get_session() + + +def get_optional_user( + request: Request, session: Annotated[Session, Depends(get_db)] +) -> User | None: + session_id = request.cookies.get(SESSION_COOKIE) + if not session_id: + return None + record = service.get_valid_session(session, session_id) + if record is None: + return None + return service.get_user(session, record.user_id) + + +def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User: + user = get_optional_user(request, session) + if user is None: + raise HTTPException(status_code=401, detail="not authenticated") + return user + + +def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User: + if user.role != "admin": + raise HTTPException(status_code=403, detail="admin only") + return user + + +def resolve_tenant(user: User | None, explicit: str | None, default: str = "default") -> str: + """Tenant comes from the session; the body/query param is a deprecated + fallback for unauthenticated callers.""" + if user is not None: + return user.tenant_id + return explicit or default diff --git a/backend/app/auth/mail.py b/backend/app/auth/mail.py new file mode 100644 index 0000000..a8cf957 --- /dev/null +++ b/backend/app/auth/mail.py @@ -0,0 +1,178 @@ +"""Magic-link delivery. SMTP when configured, dev log fallback otherwise. + +Mail design follows the web branding (see frontend/src/theme.ts): +kirchenblau header, serif headline, white card on paper background. +Email-safe by construction: table layout, inline styles only, system +fonts, no external images (many clients block them / track opens). +""" + +from __future__ import annotations + +import logging +import smtplib +from email.message import EmailMessage + +from app.settings import settings + +logger = logging.getLogger(__name__) + +# Brand palette (mirrors frontend/src/theme.ts kirchenblau). +BLUE = "#1d4a79" +BLUE_LIGHT = "#eef4fb" +INK = "#162f4c" +MUTED = "#5b6b7c" + +BLUE = "#1d4a79" +BLUE_LIGHT = "#eef4fb" +INK = "#162f4c" +MUTED = "#5b6b7c" + + +def magic_link(token: str) -> str: + return f"{settings.frontend_url.rstrip('/')}/login?token={token}" + + +def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str: + """Branded HTML mail body (table layout, inline styles, no images).""" + button = "" + if button_url: + button = ( + '' + 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 new file mode 100644 index 0000000..188f78b Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..1a68be3 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/photos/SOURCES.md b/frontend/public/photos/SOURCES.md new file mode 100644 index 0000000..d0e9112 --- /dev/null +++ b/frontend/public/photos/SOURCES.md @@ -0,0 +1,20 @@ +# Landing-page photo sources + +All files in this directory are self-hosted (no hotlinking, no tracking). +Licensed for free use including commercial purposes, no attribution +required (Pexels License). Re-fetch any time: + + docker compose exec api /opt/venv/bin/python \ + /srv/kirche-einfach/frontend/scripts/fetch-photos.py + +Rejected during visual review (do NOT re-add without checking): +US-megachurch conference screens, dark baroque interior, pandemic masks, +Dutch-language documents. + +| File | Motif | Source | +| ---------------------- | ------------------------------ | ------ | +| hero-church.webp | Stone church tower, blue sky | https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/ | +| church-pews.webp | Bright empty pews, checkerboard| https://www.pexels.com/photo/church-pews-7566207/ | +| paperwork-desk.webp | Desk with stamps and forms | https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/ | +| team-laptop.webp | Team collaborating at laptop | https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/ | +| pilot-meeting.webp | Consultation meeting, notes | https://www.pexels.com/photo/two-people-shaking-hands-5668858/ | diff --git a/frontend/public/photos/church-pews.webp b/frontend/public/photos/church-pews.webp new file mode 100644 index 0000000..6ec96c5 Binary files /dev/null and b/frontend/public/photos/church-pews.webp differ diff --git a/frontend/public/photos/hero-church.webp b/frontend/public/photos/hero-church.webp new file mode 100644 index 0000000..34bd964 Binary files /dev/null and b/frontend/public/photos/hero-church.webp differ diff --git a/frontend/public/photos/paperwork-desk.webp b/frontend/public/photos/paperwork-desk.webp new file mode 100644 index 0000000..4650cce Binary files /dev/null and b/frontend/public/photos/paperwork-desk.webp differ diff --git a/frontend/public/photos/pilot-meeting.webp b/frontend/public/photos/pilot-meeting.webp new file mode 100644 index 0000000..334a89f Binary files /dev/null and b/frontend/public/photos/pilot-meeting.webp differ diff --git a/frontend/public/photos/team-laptop.webp b/frontend/public/photos/team-laptop.webp new file mode 100644 index 0000000..8811110 Binary files /dev/null and b/frontend/public/photos/team-laptop.webp differ diff --git a/frontend/public/screenshots/hero-dark.png b/frontend/public/screenshots/hero-dark.png new file mode 100644 index 0000000..6908231 Binary files /dev/null and b/frontend/public/screenshots/hero-dark.png differ diff --git a/frontend/public/screenshots/hero-light.png b/frontend/public/screenshots/hero-light.png new file mode 100644 index 0000000..11ac6df Binary files /dev/null and b/frontend/public/screenshots/hero-light.png differ diff --git a/frontend/public/screenshots/workflows-dark.png b/frontend/public/screenshots/workflows-dark.png new file mode 100644 index 0000000..50369c0 Binary files /dev/null and b/frontend/public/screenshots/workflows-dark.png differ diff --git a/frontend/public/screenshots/workflows-light.png b/frontend/public/screenshots/workflows-light.png new file mode 100644 index 0000000..4adcd3e Binary files /dev/null and b/frontend/public/screenshots/workflows-light.png differ diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..b69a1c0 --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "Kirche Einfach", + "short_name": "Kirche", + "description": "KI-gestützte Verwaltungsplattform für Kirche", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1d4a79", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/scripts/capture.mjs b/frontend/scripts/capture.mjs new file mode 100644 index 0000000..5f8d856 --- /dev/null +++ b/frontend/scripts/capture.mjs @@ -0,0 +1,66 @@ +/* Repeatable landing-page screenshots (re-run after UI changes). + * + * Usage (frontend container, dev server running): + * npm run screenshots # light scheme (default) + * npm run screenshots -- --scheme=dark + * + * Captures real app states into public/screenshots/ (committed, so the + * landing page always shows current UI). Authenticated states are out of + * scope (magic-link login is not scriptable without SMTP). + */ + +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const outDir = join(root, "public", "screenshots"); +const base = process.env.SCREENSHOT_BASE_URL ?? "http://localhost:3000"; +const schemeArg = process.argv.find((a) => a.startsWith("--scheme=")); +const scheme = schemeArg ? schemeArg.split("=")[1] : "light"; + +const shots = [ + { name: "hero", path: "/", fullPage: true }, + { + name: "workflows", + path: "/app/workflows", + fullPage: false, + selectFlow: "intake_form", + }, +]; + +const browser = await chromium.launch(); +try { + mkdirSync(outDir, { recursive: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + colorScheme: scheme === "dark" ? "dark" : "light", + }); + await context.addInitScript( + (value) => { + try { + localStorage.setItem("kirche-color-scheme", value); + } catch { + /* private mode */ + } + }, + scheme === "dark" ? "dark" : "light", + ); + const page = await context.newPage(); + for (const shot of shots) { + await page.goto(base + shot.path, { waitUntil: "networkidle" }); + await page.waitForTimeout(1500); + if (shot.selectFlow) { + // Show the generated RJSF form instead of the default first flow. + await page.locator('[role="combobox"]').first().click(); + await page.getByRole("option", { name: shot.selectFlow }).click(); + await page.waitForTimeout(2000); + } + const file = join(outDir, `${shot.name}-${scheme}.png`); + await page.screenshot({ path: file, fullPage: shot.fullPage }); + console.log(`wrote ${file}`); + } +} finally { + await browser.close(); +} diff --git a/frontend/scripts/fetch-photos.py b/frontend/scripts/fetch-photos.py new file mode 100644 index 0000000..b36dddb --- /dev/null +++ b/frontend/scripts/fetch-photos.py @@ -0,0 +1,82 @@ +"""Fetch landing-page photos (one-time + re-runnable). + +Downloads royalty-free originals (see SOURCES.md next to the photos), +resizes to max 1600px wide and stores them as WebP q80 in +frontend/public/photos/ (self-hosted: no hotlinking, no tracking). + +Run in the api container (has network + Pillow): + docker compose exec api /opt/venv/bin/python \\ + /srv/kirche-einfach/frontend/scripts/fetch-photos.py +""" + +from __future__ import annotations + +import sys +import urllib.request +from io import BytesIO +from pathlib import Path + +try: + from PIL import Image +except ImportError: + sys.exit("Pillow required (present in the api image)") + +ROOT = Path(__file__).resolve().parent.parent.parent +OUT = ROOT / "frontend" / "public" / "photos" + + +def pexels(photo_id: int) -> str: + return ( + f"https://images.pexels.com/photos/{photo_id}/" + f"pexels-photo-{photo_id}.jpeg?auto=compress&cs=tinysrgb&w=1600" + ) + + +PHOTOS = { + # target file -> (direct file URL, source page for SOURCES.md) + "hero-church.webp": ( + pexels(31630127), + "https://www.pexels.com/photo/historic-church-with-bell-tower-in-germany-31630127/", + ), + "church-pews.webp": ( + pexels(7566207), + "https://www.pexels.com/photo/church-pews-7566207/", + ), + "paperwork-desk.webp": ( + pexels(18687845), + "https://www.pexels.com/photo/desk-with-documents-and-stamps-18687845/", + ), + "team-laptop.webp": ( + pexels(7793702), + "https://www.pexels.com/photo/a-team-looking-at-a-laptop-7793702/", + ), + "pilot-meeting.webp": ( + pexels(5668858), + "https://www.pexels.com/photo/two-people-shaking-hands-5668858/", + ), +} + + +def fetch(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": "kirche-einfach/1.0"}) + with urllib.request.urlopen(req, timeout=120) as response: + return response.read() + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + for filename, (url, _page) in PHOTOS.items(): + raw = fetch(url) + with Image.open(BytesIO(raw)) as img: + img = img.convert("RGB") + if img.width > 1600: + img = img.resize( + (1600, round(img.height * 1600 / img.width)), Image.LANCZOS + ) + target = OUT / Path(filename).with_suffix(".webp").name + img.save(target, "WEBP", quality=80, method=6) + print(f"wrote {target} ({target.stat().st_size // 1024} KB)") + + +if __name__ == "__main__": + main() diff --git a/frontend/scripts/icons.mjs b/frontend/scripts/icons.mjs new file mode 100644 index 0000000..7377455 --- /dev/null +++ b/frontend/scripts/icons.mjs @@ -0,0 +1,44 @@ +/* Generate favicon PNGs + manifest icons from favicon.svg (re-runnable). + * + * Usage (frontend container, Chromium already installed for screenshots): + * npm run icons + * + * Renders the SVG at each target size (crisp native rasterization) into + * public/. apple-touch-icon keeps the solid kirchenblau background (iOS + * fills transparency black). The 512 icon doubles as maskable: the glyph + * stays inside the inner 80% safe zone. + */ + +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { chromium } from "playwright"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const svg = readFileSync(join(root, "public", "favicon.svg"), "utf-8"); +const dataUrl = `data:image/svg+xml,${encodeURIComponent(svg)}`; + +const targets = [ + ["favicon-32x32.png", 32], + ["apple-touch-icon.png", 180], + ["icon-192.png", 192], + ["icon-512.png", 512], +]; + +const browser = await chromium.launch(); +try { + const page = await browser.newPage(); + for (const [file, size] of targets) { + await page.setViewportSize({ width: size, height: size }); + await page.setContent( + ``, + ); + 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 a8ebbdb..b32fcf4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,39 +1,161 @@ -import { useState } from "react"; -import AdminTab from "./components/AdminTab"; -import ApprovalsTab from "./components/ApprovalsTab"; -import FlowsTab from "./components/FlowsTab"; +import { Link, Navigate, Route, Routes, useNavigate } from "react-router-dom"; +import { + Anchor, + AppShell, + Button, + Center, + Container, + Group, + Loader, + Text, + Title, +} from "@mantine/core"; +import type { ReactNode } from "react"; +import { AuthProvider, useAuth } from "./auth"; +import SchemeToggle from "./components/SchemeToggle"; +import StartPage from "./pages/StartPage"; +import AccessPage from "./pages/AccessPage"; +import LoginPage from "./pages/LoginPage"; +import WorkflowsPage from "./pages/WorkflowsPage"; +import TasksPage from "./pages/TasksPage"; +import AdminPage from "./pages/AdminPage"; -type Tab = "flows" | "approvals" | "admin"; +function Nav() { + const { me, logout } = useAuth(); + const navigate = useNavigate(); -const tabStyle = (active: boolean): React.CSSProperties => ({ - fontWeight: active ? "bold" : "normal", - textDecoration: active ? "underline" : "none", -}); - -export default function App() { - const [tab, setTab] = useState("flows"); + async function onLogout() { + await logout(); + navigate("/"); + } return ( -
-

Kirche Einfach

-

KI-gestützte Verwaltungsplattform für Kirche

- - {tab === "flows" && } - {tab === "approvals" && } - {tab === "admin" && } -
+ + + 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 ? ( +
+ +
+ ) : ( + + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + )} +
+
+
+ ); +} + +export default function App() { + return ( + + + ); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 763ad9f..f0af0f7 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,22 +143,21 @@ 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: ( - taskId: string, - decision: "approve" | "reject", - approver: string, - note: string, - ) => + decide: (taskId: string, decision: "approve" | "reject", note: string) => req(`/api/approvals/${taskId}/decision`, { method: "POST", - body: JSON.stringify({ decision, approver, note }), + // Identity comes from the session; the server records the user. + body: JSON.stringify({ decision, note }), }), importFlow: (langflowFlowId: string, name?: string) => req<{ definition: FlowDefinition; version: FlowVersion }>( @@ -148,4 +176,38 @@ export const api = { req("/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 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..71daf2e --- /dev/null +++ b/frontend/src/components/AccessQueue.tsx @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Alert, + Button, + Card, + Group, + Stack, + Text, + Title, +} from "@mantine/core"; +import { + IconAlertCircle, + IconCheck, +} from "@tabler/icons-react"; +import { ApiError, api, type AccessRequest } from "../api"; +import StatusBadge from "./StatusBadge"; + +export default function AccessQueue() { + const [requests, setRequests] = useState([]); + 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 041ff96..9c068de 100644 --- a/frontend/src/components/AdminTab.tsx +++ b/frontend/src/components/AdminTab.tsx @@ -1,4 +1,22 @@ import { useCallback, useEffect, useState } from "react"; +import { + Alert, + Anchor, + Button, + Card, + Group, + Table, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { + IconAlertCircle, + IconCheck, + IconExternalLink, + IconRefresh, + IconUpload, +} from "@tabler/icons-react"; import { ApiError, LANGFLOW_URL, @@ -6,6 +24,7 @@ import { type FlowDefinition, type FlowVersion, } from "../api"; +import StatusBadge from "./StatusBadge"; export default function AdminTab() { const [flows, setFlows] = useState([]); @@ -86,61 +105,110 @@ export default function AdminTab() { return (
-

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 || "–"}{" "} - -
    • - ))} -
    -
  • - ))} -
+ + 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 || "–"} + + + + + )), + )} + +
+ )}
); } diff --git a/frontend/src/components/ApprovalsTab.tsx b/frontend/src/components/ApprovalsTab.tsx index 8c9c57e..0412f32 100644 --- a/frontend/src/components/ApprovalsTab.tsx +++ b/frontend/src/components/ApprovalsTab.tsx @@ -1,9 +1,23 @@ import { useCallback, useEffect, useState } from "react"; +import { + Alert, + Button, + Card, + Code, + Group, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react"; import { ApiError, api, type ApprovalTask } from "../api"; +import { useAuth } from "../auth"; +import StatusBadge from "./StatusBadge"; export default function ApprovalsTab() { + const { me } = useAuth(); const [tasks, setTasks] = useState([]); - const [approver, setApprover] = useState(""); const [note, setNote] = useState(""); const [error, setError] = useState(""); const [busyId, setBusyId] = useState(""); @@ -24,7 +38,7 @@ export default function ApprovalsTab() { setBusyId(taskId); setError(""); try { - const result = await api.decide(taskId, decision, approver, note); + const result = await api.decide(taskId, decision, note); setTasks((prev) => prev.map((t) => (t.id === taskId ? result.task : t)), ); @@ -40,56 +54,88 @@ export default function ApprovalsTab() { return (
-

Freigaben

- {error &&

{error}

} -
- setApprover(e.target.value)} - placeholder="Name (Bearbeiter)" - /> - + Freigaben + + {error && ( + } mb="md"> + {error} + + )} + + Angemeldet als {me?.email ?? "–"} — Entscheidungen werden diesem + Konto zugeordnet. + + + 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.status} · {t.title || t.id} ·{" "} - {t.approver ?? "–"} + + + Entschieden ({done.length}) + + {done.map((t) => ( + + + + {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 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 }} - /> - -
-