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

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

View file

@ -36,6 +36,9 @@ 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=
# Secret signing stateless session JWTs (HS256). Long random value
# (openssl rand -hex 32); empty fails closed at login time.
JWT_SECRET=
# --- frontend mode: unset/preview serves the production bundle ---
# Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it.

View file

@ -6,11 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- Declutter: shared `BaseApiClient` (LFX/Langflow), single `get_db`, `model_validate` mappers, unified LFX/Langflow error mapping, stream logic moved to `runner.py`, `sync_full_pass` single orchestration (API+CLI), shared model base (`utcnow`/`ts_column`), unified auth counters, `httpx` in `deploy_flow.py`.
- Forms: shared `components/kirche/_form_core.py` single source (codegen-inlined for LFX), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI).
- Tests (full real LFX kept, 146 green): shared `conftest` helpers (`load_flow_fixture`, `flow_helpers`, `lfx_client`, `anon_client`), `tests/_harness.py` shared with smoke script, parametrized `filename_stem`.
- Frontend: shared `formatApiError`, `<ErrorAlert/>`, `<RunsTable/>`, `URLSearchParams` queries, localized run dates.
- Declutter: shared `BaseApiClient` (LFX/Langflow) with tenacity transport retry, single `get_db`, `model_validate` mappers, unified LFX/Langflow error mapping, stream logic moved to `runner.py`, `sync_full_pass` single orchestration (API+CLI), shared model base (`utcnow`/`ts_column`), unified auth counters, `httpx` in `deploy_flow.py`, atomicwrites file writes, `secure`-package headers.
- Auth: stateless JWT sessions (HS256, 30d, HttpOnly cookie unchanged) replace the `user_sessions` table (Alembic revision drops it; all sessions invalidated at deploy); slowapi run throttle replaces the hand-rolled sliding window; mail via jinja template + fastapi-mail (same copy/fallback contract); CLIs on typer; `JWT_SECRET` enforced by `check_prod.py`.
- Forms: thin variant components over shared `components/kirche/_form_core.py` (codegen-inlined by `kirche_node()` for LFX; all 5 flows regenerated + level-4 validated), structured `visible_if` key alongside legacy markers, `LangflowClient.upsert_flow` + `scripts/push_flows_to_langflow.py` (6 canonical flows pushed to Langflow UI).
- Store/sync: single `next_version_no` query per version, validation only at the `create_version` choke point, mtime-cached `.flowsignore`.
- Tests (full real LFX kept): shared `conftest` helpers + `tests/_harness.py` adopted by fixtures and smoke script, parametrized matrices (export stems, check_prod env, deploy failures, sync listing failures, API auth probes, input_schema types), relaxed brittle asserts (ui:order sets, per-flag cookie asserts, SSE any-match).
- Frontend: react-query reads + notifications, `useForm`+zod static forms, shared `DecisionButtons`/`ErrorAlert`/`JsonView`/`FormattedDate`/`RunsTable`/`Landing` blocks, new aggregate `GET /api/runs` kills the Tasks N+1, `Promise.all` version fetches, `sharp` icons, selector-based screenshots, dead photo removed.
### Added
- Aggregate `GET /api/runs` (tenant-scoped runs with flow names, single query).
- Branched questionnaires: `[visible_if feld=Wert]` markers in a
`CustomInputForm` field's `info` (code editor) show the field only when
the condition holds — nested to any depth, with `&` for AND conditions

View file

@ -126,17 +126,14 @@ def consume_magic(
session: Annotated[Session, Depends(get_db)],
):
try:
user_session = service.consume_magic_token(session, data.token)
user, session_token = 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)
_set_session_cookie(response, session_token)
return _read_me(user)
@ -151,11 +148,8 @@ def logout(
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)
# Stateless sessions: nothing server-side to revoke — always clear
# the client cookie.
response.delete_cookie(SESSION_COOKIE, path="/")
return MessageRead(message="logged out")

View file

@ -19,13 +19,10 @@ SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
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:
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
record = service.get_valid_session(session, session_id)
if record is None:
return None
return service.get_user(session, record.user_id)
return service.get_token_user(session, token)
def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User:

View file

@ -9,8 +9,10 @@ fonts, no external images (many clients block them / track opens).
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from pathlib import Path
from jinja2 import Template
from app.settings import settings
@ -22,67 +24,62 @@ BLUE_LIGHT = "#eef4fb"
INK = "#162f4c"
MUTED = "#5b6b7c"
_TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "magic_link.html"
_template_cache: Template | None = None
def _template() -> Template:
global _template_cache
if _template_cache is None:
_template_cache = Template(_TEMPLATE_PATH.read_text(encoding="utf-8"))
return _template_cache
def magic_link(token: str) -> str:
return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str:
"""Branded HTML mail body (table layout, inline styles, no images)."""
button = ""
if button_url:
button = (
'<table role="presentation" cellpadding="0" cellspacing="0" '
'style="margin:24px auto 8px auto;"><tr>'
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
f'<a href="{button_url}" '
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
"Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;"
"font-weight:600;color:#ffffff;text-decoration:none;"
f'border-radius:8px;">{button_label}</a></td></tr></table>'
)
return f"""<!DOCTYPE html>
<html lang="de">
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
<tr>
<td bgcolor="{BLUE}" style="padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;
letter-spacing:0.02em;">Kirche Einfach</span>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
color:{INK};">{title}</h1>
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
color:{MUTED};">{intro}</p>
{button}
</td>
</tr>
</table>
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
{note}
</p>
</td></tr>
</table>
</body>
</html>"""
"""Branded HTML mail body (jinja template, byte-identical to the old f-string)."""
return _template().render(
title=title,
intro=intro,
button_label=button_label,
button_url=button_url,
note=note,
blue=BLUE,
blue_light=BLUE_LIGHT,
ink=INK,
muted=MUTED,
)
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)
"""Deliver via fastapi-mail (same never-raise contract at call sites)."""
import asyncio
from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
config = ConnectionConfig(
MAIL_USERNAME=settings.smtp_username,
MAIL_PASSWORD=settings.smtp_password,
MAIL_FROM=settings.mail_from or settings.contact_to or message["To"],
MAIL_PORT=settings.smtp_port,
MAIL_SERVER=settings.smtp_host,
MAIL_STARTTLS=True,
MAIL_SSL_TLS=False,
USE_CREDENTIALS=bool(settings.smtp_username),
)
html = message.get_body(preferencelist=("html",))
plain = message.get_body(preferencelist=("plain",))
outgoing = MessageSchema(
subject=str(message["Subject"]),
recipients=[str(message["To"])],
body=plain.get_content() if plain else "",
html=html.get_content() if html else "",
subtype=MessageType.html,
)
asyncio.run(FastMail(config).send_message(outgoing))
def _branded_mail(
@ -142,7 +139,7 @@ def send_magic_link(to_email: str, link: str) -> bool:
)
try:
_send(message)
except (OSError, smtplib.SMTPException):
except Exception: # noqa: BLE001 -- never-raise contract: delivery must not 500
logger.warning("magic-link delivery failed for %s", to_email)
return False
logger.info("magic link mailed to %s", to_email)
@ -167,7 +164,7 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
)
try:
_send(message)
except (OSError, smtplib.SMTPException):
except Exception: # noqa: BLE001 -- best-effort notification, never raises
logger.warning("access-request notification failed for %s", request_id)
return False
return True

View file

@ -1,7 +1,12 @@
"""Access requests, magic-link tokens, and sessions (DB via function args).
"""Access requests, magic-link tokens, and stateless JWT sessions.
Keep API handlers thin: all persistence lives here. Tokens and session ids
are bearer secrets — log user/request ids only, never secret values.
Keep API handlers thin: all persistence lives here. Tokens and session
JWTs are bearer secrets — log user/request ids only, never secret values.
Sessions are stateless HS256 JWTs (sub=user.id, email, role, tenant_id,
iat, exp=30d, jti) in the HttpOnly session cookie. No session table:
logout clears the cookie; role/tenant changes apply at next login (or
immediately for deleted users — the user row is reloaded per request).
"""
from __future__ import annotations
@ -9,12 +14,14 @@ from __future__ import annotations
import hashlib
import logging
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from uuid import UUID
import jwt
from sqlmodel import Session, func, select
from app.models import AccessRequest, MagicLinkToken, User, UserSession
from app.models import AccessRequest, MagicLinkToken, User
from app.models.base import utcnow
from app.settings import settings
@ -235,8 +242,43 @@ def _aware(value: datetime) -> datetime:
return value
def consume_magic_token(session: Session, token: str) -> UserSession:
"""Single-use consume → session row. used_at is set before creation."""
def _jwt_secret() -> str:
secret = settings.jwt_secret
if not secret:
raise RuntimeError("JWT_SECRET is empty — set it in .env")
return secret
def mint_session_token(user: User) -> str:
"""Issue a stateless session JWT for a user (30-day expiry)."""
now = utcnow()
return jwt.encode(
{
"sub": str(user.id),
"email": user.email,
"role": user.role,
"tenant_id": user.tenant_id,
"iat": int(now.timestamp()),
"exp": int((now + SESSION_TTL).timestamp()),
"jti": uuid.uuid4().hex,
},
_jwt_secret(),
algorithm="HS256",
)
def decode_session_token(token: str) -> dict:
"""Verify a session JWT → claims. Expired/forged → token errors."""
try:
return jwt.decode(token.strip(), _jwt_secret(), algorithms=["HS256"])
except jwt.ExpiredSignatureError as exc:
raise ExpiredTokenError("session expired") from exc
except jwt.InvalidTokenError as exc:
raise InvalidTokenError("invalid session") from exc
def consume_magic_token(session: Session, token: str) -> tuple[User, str]:
"""Single-use consume → (user, session JWT). used_at is set first."""
record = session.exec(
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
).first()
@ -252,33 +294,23 @@ def consume_magic_token(session: Session, token: str) -> UserSession:
# Mark used first: a reused token can never mint a second session.
record.used_at = utcnow()
session.add(record)
user_session = UserSession(
id=secrets.token_urlsafe(32),
user_id=user.id,
expires_at=utcnow() + SESSION_TTL,
)
session.add(user_session)
session.commit()
session.refresh(user_session)
session.refresh(user)
logger.info("magic token consumed for user %s", user.id)
return user_session
return user, mint_session_token(user)
def get_valid_session(session: Session, session_id: str) -> UserSession | None:
record = session.get(UserSession, session_id)
if record is None or record.revoked_at is not None:
def get_token_user(session: Session, token: str) -> User | None:
"""User for a session JWT, or None (expired/forged/deleted user)."""
try:
claims = decode_session_token(token)
except (InvalidTokenError, ExpiredTokenError):
return None
if _aware(record.expires_at) <= utcnow():
try:
user_id = UUID(str(claims.get("sub", "")))
except ValueError:
return None
return record
def revoke_session(session: Session, session_id: str) -> None:
record = session.get(UserSession, session_id)
if record is not None and record.revoked_at is None:
record.revoked_at = utcnow()
session.add(record)
session.commit()
return session.get(User, user_id)
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]:

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<body style="margin:0;padding:0;background-color:{{ blue_light }};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:{{ blue_light }};padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
<tr>
<td bgcolor="{{ blue }}" style="padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;
letter-spacing:0.02em;">Kirche Einfach</span>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
color:{{ ink }};">{{ title }}</h1>
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
color:{{ muted }};">{{ intro }}</p>
{% if button_url %}
<table role="presentation" cellpadding="0" cellspacing="0"
style="margin:24px auto 8px auto;"><tr>
<td bgcolor="{{ blue }}" style="border-radius:8px;">
<a href="{{ button_url }}"
style="display:inline-block;padding:12px 28px;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ button_label }}</a>
</td>
</tr></table>
{% endif %}
</td>
</tr>
</table>
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;color:{{ muted }};margin:16px 0 0 0;max-width:520px;">
{{ note }}
</p>
</td></tr>
</table>
</body>
</html>

View file

@ -17,12 +17,13 @@ from pathlib import Path
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlmodel import Session
from app.auth.deps import get_current_user, require_admin
from app.db import get_db
from app.middleware import limiter, run_limit
from app.models import User
from app.settings import settings
@ -50,6 +51,7 @@ from .schemas import (
FlowVersionCreate,
FlowVersionRead,
InputSchemaRead,
NamedRunRead,
RunRead,
RunRequest,
SyncReport,
@ -58,6 +60,7 @@ from .sync import lfx_id_for
flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
runs_router = APIRouter(prefix="/api/runs", tags=["runs"])
logger = logging.getLogger(__name__)
@ -211,7 +214,9 @@ def _resolve_version(session: Session, flow_id: UUID, version_no: int | None):
@flows_router.post("/{flow_id}/run", response_model=RunRead)
@limiter.limit(run_limit)
async def run_flow(
request: Request,
flow_id: UUID,
data: RunRequest,
session: Annotated[Session, Depends(get_db)],
@ -279,6 +284,18 @@ async def _run_stream(
return StreamingResponse(event_gen(), media_type="text/event-stream")
@runs_router.get("", response_model=list[NamedRunRead])
def list_all_runs(
session: Annotated[Session, Depends(get_db)],
user: Annotated[User, Depends(get_current_user)],
):
"""All runs of the session tenant with flow names (single query)."""
return [
NamedRunRead(flow_name=name, run=_read_run(run))
for run, name in store.list_all_runs(session, user.tenant_id)
]
@flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
def list_runs(
flow_id: UUID,

View file

@ -2,12 +2,19 @@
Consolidates base_url stripping, header building, per-request client
construction, and HTTPError → domain-error mapping. Subclasses only
declare their error types and endpoint calls.
declare their error types and endpoint calls. Transport-level failures
(connect/timeout, never 4xx) retry with exponential backoff via tenacity.
"""
from __future__ import annotations
import httpx
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
class BaseApiClient:
@ -25,3 +32,13 @@ class BaseApiClient:
return httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers()
)
def transport_retry(func): # type: ignore[no-untyped-def]
"""Retry transport errors 3x with backoff; HTTP status errors pass through."""
return retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.5, max=4),
retry=retry_if_exception_type((httpx.TransportError, httpx.TimeoutException)),
reraise=True,
)(func)

View file

@ -10,7 +10,6 @@ import hashlib
import json
import os
import re
import tempfile
from pathlib import Path
FLOWS_GLOB = "*.json"
@ -20,18 +19,32 @@ FLOWS_GLOB = "*.json"
#: validated, or shown in the frontend.
IGNORE_FILENAME = ".flowsignore"
# mtime-keyed cache: the watcher re-reads per event, the file rarely changes.
_ignore_cache: dict[str, tuple[float, set[str]]] = {}
def ignored_stems(flows_dir: str | Path) -> set[str]:
"""Stems excluded from the pipeline (empty when no ignore file)."""
key = str(Path(flows_dir))
try:
mtime = (Path(flows_dir) / IGNORE_FILENAME).stat().st_mtime
except OSError:
_ignore_cache.pop(key, None)
return set()
cached = _ignore_cache.get(key)
if cached is not None and cached[0] == mtime:
return cached[1]
try:
text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8")
except OSError:
return set()
return {
stems = {
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("#")
}
_ignore_cache[key] = (mtime, stems)
return stems
def filename_stem(name: str) -> str:
@ -97,25 +110,16 @@ def find_reuse_path(flows_dir: str | Path, doc: dict) -> tuple[Path | None, bool
def write_flow_bytes(target: Path, doc: dict) -> Path:
"""Write a flow document to an explicit path atomically (tmp + rename).
The file ends up world-readable (0o644): mkstemp creates 0o600, which
would lock out the other containers (Langflow/LFX run as different
users and crash on unreadable seed files).
The file ends up world-readable (0o644): atomicwrites follows the
umask, which would lock out the other containers (Langflow/LFX run
as different users and crash on unreadable seed files).
"""
from atomicwrites import atomic_write
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=str(target.parent), prefix=f".{target.stem}-", suffix=".tmp"
)
try:
with os.fdopen(fd, "wb") as tmp:
tmp.write(canonical_bytes(doc))
os.replace(tmp_name, target)
os.chmod(target, 0o644)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
with atomic_write(target, mode="wb", overwrite=True) as tmp:
tmp.write(canonical_bytes(doc))
os.chmod(target, 0o644)
return target

View file

@ -8,7 +8,7 @@ from __future__ import annotations
import httpx
from .base_client import BaseApiClient
from .base_client import BaseApiClient, transport_retry
class LangflowError(Exception):
@ -29,6 +29,7 @@ class LangflowClient(BaseApiClient):
def __init__(self, base_url: str, api_key: str = "", timeout: float = 60.0) -> None:
super().__init__(base_url, timeout=timeout, api_key=api_key)
@transport_retry
async def list_flows(self) -> list[dict]:
"""List flows (header mode keeps payloads small)."""
try:
@ -44,6 +45,7 @@ class LangflowClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
@transport_retry
async def export_flow(self, langflow_flow_id: str) -> dict:
"""Export one flow as Langflow JSON (FlowRead: id/name/description/data)."""
try:
@ -58,6 +60,7 @@ class LangflowClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LangflowUnavailable(f"Langflow unreachable: {exc}") from exc
@transport_retry
async def upsert_flow(self, doc: dict) -> dict:
"""Create or update a flow in Langflow by name (authoring UI refresh).

View file

@ -6,7 +6,7 @@ from dataclasses import dataclass
import httpx
from .base_client import BaseApiClient
from .base_client import BaseApiClient, transport_retry
class LfxError(Exception):
@ -56,6 +56,7 @@ class LfxClient(BaseApiClient):
def __init__(self, base_url: str, timeout: float = 120.0, api_key: str | None = None) -> None:
super().__init__(base_url, timeout=timeout, api_key=api_key)
@transport_retry
async def health(self) -> dict:
try:
async with self._client() as client:
@ -65,6 +66,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
@transport_retry
async def list_flows(self) -> list[dict]:
try:
async with self._client() as client:
@ -76,6 +78,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
@transport_retry
async def upload_flow(
self, flow_json: dict, *, flow_id: str | None = None, replace: bool = False
) -> LfxUploadResult:
@ -140,6 +143,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
@transport_retry
async def flow_info(self, lfx_flow_id: str) -> dict:
try:
async with self._client() as client:
@ -151,6 +155,7 @@ class LfxClient(BaseApiClient):
except httpx.HTTPError as exc:
raise LfxUnavailable(f"LFX unreachable at {self.base_url}: {exc}") from exc
@transport_retry
async def delete_flow(self, lfx_flow_id: str) -> None:
try:
async with self._client() as client:

View file

@ -76,6 +76,13 @@ class RunRead(BaseModel):
finished_at: datetime | None
class NamedRunRead(BaseModel):
"""A run with its flow name (GET /api/runs aggregate, kills N+1)."""
flow_name: str
run: RunRead
class ApprovalCreate(BaseModel):
"""Callback contract: a flow reports a terminal approval state."""

View file

@ -129,12 +129,18 @@ def validate_flow_json(flow_json: dict) -> None:
raise InvalidError("langflow_json.data must contain 'nodes' and 'edges' lists")
def create_version(session: Session, flow_id: UUID, data: FlowVersionCreate) -> FlowVersion:
def create_version(
session: Session,
flow_id: UUID,
data: FlowVersionCreate,
*,
version_no: int | None = None,
) -> FlowVersion:
get_definition(session, flow_id)
validate_flow_json(data.langflow_json)
version = FlowVersion(
flow_id=flow_id,
version_no=next_version_no(session, flow_id),
version_no=version_no if version_no is not None else next_version_no(session, flow_id),
langflow_json=data.langflow_json,
author=data.author,
# Single choke point: sync, import, and API creation all cache the
@ -256,6 +262,18 @@ def list_runs(session: Session, flow_id: UUID, tenant_id: str | None = None) ->
return list(session.exec(query).all())
def list_all_runs(session: Session, tenant_id: str) -> list[tuple[FlowRun, str]]:
"""All runs of a tenant with flow names (single query, kills N+1)."""
rows = session.exec(
select(FlowRun, FlowDefinition.name)
.join(FlowVersion, FlowVersion.id == FlowRun.version_id)
.join(FlowDefinition, FlowDefinition.id == FlowVersion.flow_id)
.where(FlowRun.tenant_id == tenant_id)
.order_by(FlowRun.created_at.desc())
).all()
return [(run, name) for run, name in rows]
def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask:
run = session.get(FlowRun, data.run_id)
if run is None:

View file

@ -64,8 +64,8 @@ async def sync_document(
"skipped" (content unchanged). Raises store.ConflictError on name
clashes, store.InvalidError on invalid JSON, LfxError when deploy fails.
With deploy=False a version is recorded without touching LFX.
Validation lives in store.create_version (single choke point).
"""
store.validate_flow_json(doc)
name = doc.get("name") or Path(source_filename).stem
with sync_lock:
definition = store.get_definition_by_source_path(session, source_filename)
@ -102,9 +102,8 @@ async def sync_document(
session,
definition.id,
FlowVersionCreate(langflow_json=doc, author=author),
version_no=version_no,
)
if version.version_no != version_no: # pragma: no cover - lock prevents this
raise store.ConflictError(f"concurrent version write for flow {definition.id}")
version = store.mark_deployed(session, version, uploaded.id)
if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
try:

View file

@ -4,13 +4,16 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
from .auth.api import access_router, auth_router
from .flows.api import approvals_router, flows_router
from .flows.api import approvals_router, flows_router, runs_router
from .flows.sync import format_report, run_sync_blocking, watch_forever
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware
from .middleware import SecurityHeadersMiddleware, limiter
from .settings import settings
logger = logging.getLogger(__name__)
@ -64,12 +67,23 @@ app = FastAPI(
# entrypoint.sh) trusts its forwarding headers so Secure cookies, client
# IPs (throttle) and absolute URLs are correct. The API is not directly
# internet-reachable (proxy-only), so trusting them is safe.
app.state.limiter = limiter
app.add_middleware(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes)
app.add_middleware(RunThrottleMiddleware)
app.add_middleware(SlowAPIMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
@app.exception_handler(RateLimitExceeded)
async def _throttle_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
retry_after = getattr(exc, "retry_after", None) or "60"
response.headers["Retry-After"] = str(retry_after)
return response
app.include_router(flows_router)
app.include_router(approvals_router)
app.include_router(runs_router)
app.include_router(access_router)
app.include_router(auth_router)

View file

@ -1,79 +1,59 @@
"""Edge hardening: run throttling, security headers.
"""Edge hardening: run throttling (slowapi), security headers (secure).
Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
main.py). All limits are settings-driven so tests can shrink them. State
is in-process memory: correct for the single-replica SQLite deployment;
revisit with Redis if the API ever scales horizontally.
main.py). The run throttle is settings-driven so tests can shrink it.
State is in-process memory: correct for the single-replica SQLite
deployment; revisit with Redis if the API ever scales horizontally.
"""
from __future__ import annotations
import time
from collections import defaultdict
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.datastructures import MutableHeaders
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.settings import settings
# POST .../run is the expensive endpoint (LFX execution, LLM cost):
# per client IP and minute.
RUN_PATH_SUFFIX = "/run"
THROTTLE_WINDOW_SECONDS = 60.0
# per client IP and minute, limit read live from settings so tests can
# shrink it without re-creating the app.
limiter = Limiter(
key_func=get_remote_address,
storage_uri="memory://",
default_limits=[],
)
_hits: dict[str, list[float]] = defaultdict(list)
def run_limit() -> str:
return f"{settings.run_throttle_per_minute}/minute"
def reset_throttle() -> None:
"""Drop all counters (tests)."""
_hits.clear()
def _client_ip(request: Request) -> str:
# Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
# scope["client"] from X-Forwarded-For.
if request.client is not None:
return request.client.host
forwarded = request.headers.get("x-forwarded-for", "")
return forwarded.split(",")[0].strip() or "unknown"
class RunThrottleMiddleware:
"""Per-IP sliding window on POST .../run (429 + Retry-After)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] != "http"
or scope.get("method") != "POST"
or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
):
await self.app(scope, receive, send)
return
request = Request(scope)
now = time.monotonic()
window_start = now - THROTTLE_WINDOW_SECONDS
bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
if len(bucket) >= settings.run_throttle_per_minute:
retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
response.headers["Retry-After"] = str(retry_after)
await response(scope, receive, send)
return
bucket.append(now)
_hits[_client_ip(request)] = bucket
await self.app(scope, receive, send)
try:
limiter._storage.reset() # type: ignore[attr-defined]
except AttributeError:
pass
class SecurityHeadersMiddleware:
"""Baseline response headers (TLS/HSTS live on the proxy)."""
"""Baseline response headers (TLS/HSTS live on the proxy).
Built with the ``secure`` package; values stay byte-identical to the
previous hand-rolled version (see test_middleware).
"""
def __init__(self, app: ASGIApp) -> None:
import secure as secure_lib
self.app = app
self.secure_headers = secure_lib.Secure(
xcto=secure_lib.XContentTypeOptions().nosniff(),
xfo=secure_lib.XFrameOptions().sameorigin(),
referrer=secure_lib.ReferrerPolicy().strict_origin_when_cross_origin(),
csp=secure_lib.ContentSecurityPolicy().frame_ancestors("'self'"),
)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
@ -83,10 +63,8 @@ class SecurityHeadersMiddleware:
async def secured(message: Message) -> None:
if message["type"] == "http.response.start":
headers = MutableHeaders(raw=message.setdefault("headers", []))
headers["X-Content-Type-Options"] = "nosniff"
headers["X-Frame-Options"] = "SAMEORIGIN"
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
headers["Content-Security-Policy"] = "frame-ancestors 'self'"
for key, value in self.secure_headers.headers.items():
headers[key] = value
await send(message)
await self.app(scope, receive, secured)

View file

@ -1,5 +1,5 @@
from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
from .user import AccessRequest, MagicLinkToken, User, UserSession
from .user import AccessRequest, MagicLinkToken, User
__all__ = [
"AccessRequest",
@ -9,5 +9,4 @@ __all__ = [
"FlowVersion",
"MagicLinkToken",
"User",
"UserSession",
]

View file

@ -47,19 +47,7 @@ class MagicLinkToken(SQLModel, table=True):
user_id: UUID = Field(foreign_key="users.id", index=True)
token_hash: str = Field(index=True, unique=True, max_length=64)
expires_at: datetime = Field(sa_column=ts_column())
# Set before the session row is created, so a reused token can never
# Set before the session JWT is minted, so a reused token can never
# mint a second session.
used_at: datetime | None = Field(default=None, sa_column=ts_column())
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
class UserSession(SQLModel, table=True):
"""An opaque session id in the DB (cookie value). Revocable, expirable."""
__tablename__ = "user_sessions"
id: str = Field(primary_key=True, max_length=128)
user_id: UUID = Field(foreign_key="users.id", index=True)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
expires_at: datetime = Field(sa_column=ts_column())
revoked_at: datetime | None = Field(default=None, sa_column=ts_column())

View file

@ -42,7 +42,9 @@ class Settings(BaseSettings):
run_throttle_per_minute: int = 60
# --- access management (magic-link auth, see app/auth) ---
# Public frontend URL (magic links point at /login?token= there).
# Secret signing session JWTs (stateless cookies). Long random value;
# empty fails closed at login time — set it in prod (.env JWT_SECRET).
jwt_secret: str = "" # Public frontend URL (magic links point at /login?token= there).
frontend_url: str = "http://localhost:3000"
# Comma-separated emails that become admins (role assignment + bootstrap).
admin_emails: str = ""

View file

@ -44,10 +44,30 @@ derives a JSON Schema + uiSchema from the node's template
The API watcher syncs + deploys it automatically; the form schema is
regenerated per version.
## Branched questionnaires (`visible_if`)
## Structured `visible_if` (preferred) + markers (fallback)
A field can appear only when other fields have certain values — nested
to any depth. Declare it in the code editor with a marker in `info`:
to any depth. Preferred: structured `visible_if` key (no quoting
mini-language, rename-refactorable, validated at sync time with warnings
instead of silent fail-open):
```python
(
DropdownInput(
name="bestandteil",
display_name="Welcher Bestandteil der Kirche?",
options=["Glocke", "Orgel"],
value=[],
info="Mehrfachauswahl möglich",
visible_if=[{"field": "gebaeude", "value": "Kirche"}],
required=True,
),
)
```
Legacy `[visible_if ...]` markers in `info` still work as deprecated
fallback (same syntax, stripped from help text). The parser emits
`allOf`/`if`/`then` branches either way (see below).
```python
(
@ -149,20 +169,23 @@ envelope and merge normally), and unasked code defaults stay out of
the output. Without the list — raw dicts, Langflow playground — all
visible declared defaults apply as before.
## Variant flows (own component per flow)
## Variant flows (own component per flow, shared core)
A flow whose fields differ from `CustomInputForm` gets its own
standalone component (see `bauvorhaven_form.py` → "Bauvorhaben Form"
in the palette): same class pattern, own `inputs` list. Standalone
means it: no imports between form modules — the LFX runtime executes
the embedded module source without our `kirche` package, so every form
component must be self-contained (stdlib + `lfx` only). Copy the file
as the starting point for future forms.
A flow whose fields differ from `CustomInputForm` gets its own thin
component (see `bauvorhaben_form.py` → "Bauvorhaben Form" in the
palette): same class pattern, own `inputs` list, shared runtime from
`kirche._form_core` (`from kirche._form_core import ...` plus the
`# <-- _form_core inlined ... -->` marker). The LFX runtime executes
the embedded module source without our `kirche` package, so
`scripts/make_flow_fixtures.py:kirche_node()` inlines the core into the
exported `code` — one truth in the repo, self-contained JSON. Copy the
file as the starting point for future forms.
Rule: code-declared inputs must equal template fields
(`test_embedded_code_matches_template_fields` enforces it; required
Multiselect uses the `[" "]` sentinel). Generate node + flow with
`kirche_node(..., component_cls, code_override)` and
(`test_embedded_code_matches_template_fields` enforces it, plus
no-sibling-import + core-present asserts; required Multiselect uses
the `[" "]` sentinel). Generate node + flow with `kirche_node(...)`
(which composes thin source + `_inline_core()` automatically) and
`build_bauvorhaben()` in `backend/scripts/make_flow_fixtures.py`
rather than hand-editing JSON. Restart Langflow afterwards so the
palette picks up the new component, then build/upload the flow.

View file

@ -1,11 +1,9 @@
"""Bauvorhaben form component for kirche-einfach flows.
Standalone by design: this module shares NO code with ``form_inputs``
(no imports from it). The LFX runtime executes the embedded module
source without our ``kirche`` package, so every form component must be
self-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated
machinery below into a shared import — it would break flow execution.
Copy this file as the starting point for future forms.
Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
inlines the core into the exported ``code`` so the LFX runtime stays
self-contained. Copy this file as the starting point for future forms.
A flow that starts with :class:`CustomInputBauvorhaben` declares the
fields of its web form. The API reads the exported node template
@ -32,8 +30,8 @@ Langflow playground), all visible declared defaults apply.
from __future__ import annotations
import json
import re
import json # noqa: F401 -- used by inlined _form_core at runtime
import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar
from lfx.custom import Component
@ -48,94 +46,16 @@ from lfx.io import (
)
from lfx.schema.data import Data
from kirche._form_core import (
PLUMBING_INPUTS,
_field_conditions,
_json_safe,
collect_form_data,
parse_envelope,
)
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _json_safe(value):
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
quoted = False
for char in text:
if char == '"':
quoted = not quoted
buf.append(char)
elif char == "&" and not quoted:
parts.append("".join(buf))
buf = []
else:
buf.append(char)
parts.append("".join(buf))
return [part.strip() for part in parts if part.strip()]
def _parse_condition(token: str):
field, sep, value = token.partition("=")
field, value = field.strip(), value.strip()
if not sep or not field or not value:
return None
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value = value[1:-1]
lowered = value.lower()
if lowered == "true":
return field, True
if lowered == "false":
return field, False
try:
return field, int(value)
except ValueError:
pass
try:
return field, float(value)
except ValueError:
pass
return field, value
def _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output. Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
# (LFX executes embedded source without the kirche package) -->
class CustomInputBauvorhaben(Component):
@ -246,9 +166,11 @@ class CustomInputBauvorhaben(Component):
"""
inputs = getattr(self, "_inputs", None)
info = ""
structured = None
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
structured = getattr(inputs[name], "visible_if", None)
for trigger, expected in _field_conditions(info, structured):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
@ -262,33 +184,14 @@ class CustomInputBauvorhaben(Component):
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
payload, asked = parse_envelope(self.form_values)
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
field_values = {name: _json_safe(self._field_value(name)) for name in declared}
effective, scope = collect_form_data(declared, field_values, payload, asked)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code

View file

@ -34,8 +34,8 @@ upload comes later.
from __future__ import annotations
import json
import re
import json # noqa: F401 -- used by inlined _form_core at runtime
import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar
from lfx.custom import Component
@ -56,97 +56,16 @@ from lfx.io import (
)
from lfx.schema.data import Data
from kirche._form_core import (
PLUMBING_INPUTS,
_field_conditions,
_json_safe,
collect_form_data,
parse_envelope,
)
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _json_safe(value):
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
quoted = False
for char in text:
if char == '"':
quoted = not quoted
buf.append(char)
elif char == "&" and not quoted:
parts.append("".join(buf))
buf = []
else:
buf.append(char)
parts.append("".join(buf))
return [part.strip() for part in parts if part.strip()]
def _parse_condition(token: str):
field, sep, value = token.partition("=")
field, value = field.strip(), value.strip()
if not sep or not field or not value:
return None
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value = value[1:-1]
lowered = value.lower()
if lowered == "true":
return field, True
if lowered == "false":
return field, False
try:
return field, int(value)
except ValueError:
pass
try:
return field, float(value)
except ValueError:
pass
return field, value
def _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output (otherwise the whole envelope JSON would
#: leak in as a field value on every path without an asked-fields list).
#: Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync; this module must
#: stay import-light for the LFX runtime and cannot import it).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
# (LFX executes embedded source without the kirche package) -->
class CustomInputForm(Component):
@ -272,9 +191,11 @@ class CustomInputForm(Component):
"""
inputs = getattr(self, "_inputs", None)
info = ""
structured = None
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
structured = getattr(inputs[name], "visible_if", None)
for trigger, expected in _field_conditions(info, structured):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
@ -288,33 +209,14 @@ class CustomInputForm(Component):
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
payload, asked = parse_envelope(self.form_values)
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
field_values = {name: _json_safe(self._field_value(name)) for name in declared}
effective, scope = collect_form_data(declared, field_values, payload, asked)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code

View file

@ -1,11 +1,9 @@
"""Mitarbeiter-einstellen form component for kirche-einfach flows.
Standalone by design: this module shares NO code with the other form
modules (no imports from them). The LFX runtime executes the embedded
module source without our ``kirche`` package, so every form component
must be self-contained (stdlib + ``lfx`` only). Do NOT refactor the
duplicated machinery below into a shared import — it would break flow
execution. Copy this file (or ``bauvorhaben_form.py``) as the starting
Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
inlines the core into the exported ``code`` so the LFX runtime stays
self-contained. Copy this file (or ``bauvorhaben_form.py``) as the starting
point for future forms.
A flow that starts with :class:`CustomInputMitarbeiter` declares the
@ -33,8 +31,8 @@ Langflow playground), all visible declared defaults apply.
from __future__ import annotations
import json
import re
import json # noqa: F401 -- used by inlined _form_core at runtime
import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar
from lfx.custom import Component
@ -46,94 +44,16 @@ from lfx.io import (
)
from lfx.schema.data import Data
from kirche._form_core import (
PLUMBING_INPUTS,
_field_conditions,
_json_safe,
collect_form_data,
parse_envelope,
)
def _text(value) -> str:
if value is None:
return ""
if hasattr(value, "text"):
return value.text or ""
return str(value)
def _json_safe(value):
"""Plain JSON value for one field: unwrap LFX wrappers, keep scalars."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, list):
return [_json_safe(item) for item in value]
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
text = getattr(value, "text", None)
if isinstance(text, str):
return text
data = getattr(value, "data", None)
if isinstance(data, dict):
return _json_safe(data)
return str(value)
def _split_conditions(text: str) -> list:
parts: list = []
buf: list = []
quoted = False
for char in text:
if char == '"':
quoted = not quoted
buf.append(char)
elif char == "&" and not quoted:
parts.append("".join(buf))
buf = []
else:
buf.append(char)
parts.append("".join(buf))
return [part.strip() for part in parts if part.strip()]
def _parse_condition(token: str):
field, sep, value = token.partition("=")
field, value = field.strip(), value.strip()
if not sep or not field or not value:
return None
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value = value[1:-1]
lowered = value.lower()
if lowered == "true":
return field, True
if lowered == "false":
return field, False
try:
return field, int(value)
except ValueError:
pass
try:
return field, float(value)
except ValueError:
pass
return field, value
def _field_conditions(info: object) -> list:
"""``[(trigger, value)]`` from a field's ``info`` markers."""
conditions: list = []
def _collect(match) -> str:
for token in _split_conditions(match.group(1)):
parsed = _parse_condition(token)
if parsed is not None:
conditions.append(parsed)
return ""
_VISIBLE_RE.sub(_collect, info if isinstance(info, str) else "")
return conditions
#: Reserved input carrying the run envelope — a real runtime input, so it
#: must stay out of the output. Never name a form field ``form_values``.
PLUMBING_INPUTS = frozenset({"form_values"})
#: ``[visible_if ...]`` marker (same syntax as the backend parser in
#: ``app/flows/input_schema.py`` — keep the two in sync).
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
# (LFX executes embedded source without the kirche package) -->
class CustomInputMitarbeiter(Component):
@ -223,9 +143,11 @@ class CustomInputMitarbeiter(Component):
"""
inputs = getattr(self, "_inputs", None)
info = ""
structured = None
if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or ""
for trigger, expected in _field_conditions(info):
structured = getattr(inputs[name], "visible_if", None)
for trigger, expected in _field_conditions(info, structured):
if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser.
@ -239,33 +161,14 @@ class CustomInputMitarbeiter(Component):
return True
def collect(self) -> Data:
try:
envelope = json.loads(_text(self.form_values) or "{}")
except (json.JSONDecodeError, ValueError, AttributeError):
envelope = {}
payload: dict = {}
asked: list | None = None
if isinstance(envelope, dict):
# Asked-field list counts only in true envelope shape
# ({"payload": {...}, "fields": [...]}); a raw dict or a form
# field literally named "fields" must not confuse it.
if isinstance(envelope.get("payload"), dict):
payload = envelope["payload"]
fields = envelope.get("fields")
if isinstance(fields, list) and all(isinstance(item, str) for item in fields):
asked = [item for item in fields if item]
else:
payload = envelope
payload, asked = parse_envelope(self.form_values)
# Effective trigger values: submitted first, code defaults after.
declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items():
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
field_values = {name: _json_safe(self._field_value(name)) for name in declared}
effective, scope = collect_form_data(declared, field_values, payload, asked)
# Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground).
scope = [name for name in (asked if asked is not None else declared) if name in declared]
data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,46 @@
"""drop user_sessions (stateless JWT sessions)
Revision ID: c4d8f2a1e6b9
Revises: b7e2d4a1c9f3
Create Date: 2026-09-28 00:00:00.000000
Sessions are stateless HS256 JWTs in the HttpOnly cookie (see
app/auth/service.py mint_session_token). All sessions are invalidated
at deploy — users re-login via magic link.
"""
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c4d8f2a1e6b9"
down_revision: str | Sequence[str] | None = "b7e2d4a1c9f3"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
op.drop_index(op.f("ix_user_sessions_user_id"), table_name="user_sessions")
op.drop_table("user_sessions")
def downgrade() -> None:
"""Downgrade schema."""
op.create_table(
"user_sessions",
sa.Column("id", sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_user_sessions_user_id"), "user_sessions", ["user_id"], unique=False)

View file

@ -5,10 +5,19 @@ description = "Kirche Einfach - KI-gestuetzte Verwaltungsplattform fuer Kirche"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.14",
"atomicwrites>=1.4.1",
"fastapi[standard]>=0.115.0",
"fastapi-mail>=1.4.1",
"httpx>=0.28",
"jinja2>=3.1",
"pydantic-settings>=2.0",
"pyjwt>=2.10",
"python-slugify>=8.0",
"secure>=1.0",
"slowapi>=0.1.9",
"sqlmodel>=0.0.24",
"tenacity>=9.0",
"typer>=0.15",
"uvicorn[standard]>=0.30.0",
"watchfiles>=0.24",
]

View file

@ -11,10 +11,11 @@ from app (stdlib only), prints no secret values.
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import typer
BACKEND = Path(__file__).resolve().parent.parent
REPO_ROOT = BACKEND.parent
@ -57,6 +58,7 @@ def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
# Shared + service API keys.
secret("CALLBACK_TOKEN", MIN_TOKEN, "callback token")
secret("JWT_SECRET", MIN_TOKEN, "session JWT secret")
secret("LANGFLOW_API_KEY", MIN_API_KEY, "langflow api key")
placeholder("LANGFLOW_API_KEY", ("sk-",))
secret("LFX_API_KEY", MIN_API_KEY, "lfx api key")
@ -87,11 +89,9 @@ def check(values: dict[str, str]) -> tuple[list[str], list[str]]:
return failures, warnings
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--env", default=str(REPO_ROOT / ".env"))
args = parser.parse_args()
path = Path(args.env)
def main(env: str = typer.Option(str(REPO_ROOT / ".env"), "--env")) -> int:
"""Validate a prod .env file (typer CLI; check/parse_env stay importable)."""
path = Path(env)
if not path.is_file():
print(f"no env file at {path}")
return 1
@ -108,4 +108,4 @@ def main() -> int:
if __name__ == "__main__":
sys.exit(main())
sys.exit(typer.run(main))

View file

@ -7,11 +7,11 @@ Usage:
from __future__ import annotations
import argparse
import json
import sys
import httpx
import typer
def post(api: str, path: str, body: dict) -> dict:
@ -26,23 +26,22 @@ def post(api: str, path: str, body: dict) -> dict:
return {"status": 0, "body": str(exc)[:2000]}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api", default="http://localhost:8000")
parser.add_argument("--langflow-id", required=True)
parser.add_argument("--name", default=None)
parser.add_argument("--author", default="")
parser.add_argument("--no-deploy", action="store_true")
args = parser.parse_args()
def main(
langflow_id: str = typer.Option(..., "--langflow-id"),
api: str = typer.Option("http://localhost:8000", "--api"),
name: str | None = typer.Option(None, "--name"),
author: str = typer.Option("", "--author"),
no_deploy: bool = typer.Option(False, "--no-deploy"),
) -> int:
"""Import a flow from Langflow via the running API (post() stays importable)."""
result = post(
args.api,
api,
"/api/flows/import",
{
"langflow_flow_id": args.langflow_id,
"name": args.name,
"author": args.author,
"deploy": not args.no_deploy,
"langflow_flow_id": langflow_id,
"name": name,
"author": author,
"deploy": not no_deploy,
},
)
print(json.dumps(result, indent=2, ensure_ascii=False))
@ -50,4 +49,4 @@ def main() -> int:
if __name__ == "__main__":
sys.exit(main())
sys.exit(typer.run(main))

View file

@ -29,6 +29,74 @@ FLOW_DIR = REPO_ROOT / "backend" / "flows"
HANDLE_QUOTE = "\u0153" # ReactFlow handle-id quoting used by Langflow exports
FORM_CORE_PATH = REPO_ROOT / "backend" / "components" / "kirche" / "_form_core.py"
INLINE_MARKER = "# <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()"
def _inline_core() -> str:
"""Body of _form_core.py minus docstring/future/std-imports (already present)."""
text = FORM_CORE_PATH.read_text(encoding="utf-8")
lines = text.splitlines()
body: list[str] = []
skip_docstring = True
in_docstring = False
for line in lines:
stripped = line.strip()
if skip_docstring:
if stripped.startswith('"""') and not in_docstring:
if stripped.count('"""') >= 2:
skip_docstring = False
else:
in_docstring = True
continue
if in_docstring:
if '"""' in stripped:
in_docstring = False
skip_docstring = False
continue
if stripped in (
"from __future__ import annotations",
"import json",
"import re",
"",
):
continue
skip_docstring = False
if stripped in ("from __future__ import annotations", "import json", "import re"):
continue
body.append(line)
return "\n".join(body).strip() + "\n"
def _compose_component_code(source: str) -> str:
"""Replace the sibling import + marker with inlined core (LFX self-contained)."""
core = _inline_core()
out: list[str] = []
skipping_import = False
for line in source.splitlines():
stripped = line.strip()
if not skipping_import and stripped.startswith(
("from kirche._form_core import", "from ._form_core import")
):
out.append(core.rstrip())
# Multi-line parenthesized import: skip continuation lines
# up to and including the closing paren.
if "(" in stripped and ")" not in stripped:
skipping_import = True
continue
if skipping_import:
if ")" in stripped:
skipping_import = False
continue
if "_form_core inlined by" in stripped:
continue
out.append(line)
composed = "\n".join(out)
assert "from kirche" not in composed, "sibling import survived inlining"
assert "from ._" not in composed, "relative import survived inlining"
return composed
# --------------------------------------------------------------------------
# node builders
@ -118,7 +186,7 @@ def kirche_node(
comp_name = getattr(component_cls, "name", None) or component_cls.__name__
inst = component_cls()
if code_override is not None:
inst._code = code_override
inst._code = _compose_component_code(code_override)
frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
inst._map_parameters_on_frontend_node(frontend_node)
node = frontend_node.to_dict(keep_name=False)
@ -126,6 +194,10 @@ def kirche_node(
inst._map_parameters_on_template(node["template"])
if not getattr(inst, "_code", None):
inst.set_class_code()
# Always compose: default path may already carry thin source with the
# sibling import (Component base sets _code on init); override path is
# pre-composed but re-composing is idempotent (no import lines left).
inst._code = _compose_component_code(inst._code)
node["template"]["code"] = {
"advanced": True,
"api_editable": False,

View file

@ -37,6 +37,9 @@ LFX_PORT = _free_port()
API_PORT = _free_port()
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
# Stateless admin session JWT, minted in main() after planting the user.
_SMOKE_JWT = ""
def wait_healthy(url: str, timeout: int = 180) -> None:
deadline = time.time() + timeout
@ -54,9 +57,9 @@ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | s
import httpx
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c:
# Management + runs are login-gated; the smoke admin session is
# planted directly in the throwaway DB (see main()).
c.cookies.set("kirche_session", "smoke-session", domain="127.0.0.1")
# Management + runs are login-gated; the smoke admin JWT is minted
# in main() (stateless sessions, see app/auth/service.py).
c.cookies.set("kirche_session", _SMOKE_JWT, domain="127.0.0.1")
r = c.request(method, path, json=body)
try:
return r.status_code, r.json()
@ -90,27 +93,31 @@ def main() -> None:
app_settings.database_url = db_url
alembic_command.upgrade(cfg, "head")
# Plant an admin session: management + runs require login, and there is
# no SMTP in smoke to deliver a magic link.
from datetime import UTC, datetime, timedelta
# Plant an admin user + mint its session JWT: management + runs
# require login, and there is no SMTP in smoke to deliver a magic link.
# Sessions are stateless (see app/auth/service.py) — the cookie value
# below is the JWT sent as `kirche_session`.
global _SMOKE_JWT
import os
from sqlmodel import Session, create_engine
from app.models import User, UserSession
from app.auth import service as auth_service
from app.models import User
from app.settings import settings as app_settings
if not app_settings.jwt_secret:
app_settings.jwt_secret = os.environ.get(
"JWT_SECRET", "smoke-jwt-secret-32-chars-minimum!!"
)
engine = create_engine(db_url)
with Session(engine) as s:
user = User(name="smoke", email="smoke@example.com", role="admin")
s.add(user)
s.flush()
s.add(
UserSession(
id="smoke-session",
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(hours=1),
)
)
s.commit()
s.refresh(user)
_SMOKE_JWT = auth_service.mint_session_token(user)
print("smoke admin JWT minted")
engine.dispose()
env = {

View file

@ -9,12 +9,12 @@ Exit codes: 0 ok, 1 sync errors, 2 flows dir missing.
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
import typer
from sqlmodel import Session
from app.db import get_engine, reset_engine
@ -80,18 +80,17 @@ async def main_async(args) -> int:
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--pull", action="store_true", help="export all Langflow flows to backend/flows first"
)
parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX")
parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)")
args = parser.parse_args()
if not args.pull and not args.sync:
args.sync = True
def main(
pull: bool = typer.Option(False, "--pull", help="export all Langflow flows first"),
sync: bool = typer.Option(False, "--sync", help="reconcile backend/flows into DB + LFX"),
dir: str | None = typer.Option(None, "--dir", help="flows directory"),
) -> int:
"""Reconcile flows (typer CLI; run_sync/run_full_pass stay importable)."""
if not pull and not sync:
sync = True
args = type("Args", (), {"pull": pull, "sync": sync, "dir": dir})()
return asyncio.run(main_async(args))
if __name__ == "__main__":
sys.exit(main())
sys.exit(typer.run(main))

View file

@ -66,3 +66,42 @@ def migrate_throwaway(backend_dir: Path) -> None:
cfg = Config()
cfg.set_main_option("script_location", str(backend_dir / "migrations"))
alembic_command.upgrade(cfg, "head")
# Single constant: lfx prefers LANGFLOW_API_KEY over LFX_API_KEY, so the
# repo .env value must be cleared wherever a test key is used.
TEST_LFX_KEY = "test-lfx-key"
def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[no-untyped-def]
"""Real uvicorn server in a daemon thread. Returns (server, thread)."""
import threading
import time
import uvicorn
config = uvicorn.Config(app, host=host, port=port, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 30
while not server.started and time.time() < deadline:
time.sleep(0.05)
assert server.started, "uvicorn test server did not start"
return server, thread
def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> object:
"""Idempotent test user + session JWT (shared with smoke pattern)."""
from sqlmodel import select
from app.auth import service
from app.models import User
user = session.exec(select(User).where(User.email == email)).first()
if user is None:
user = User(name=name, email=email, role=role)
session.add(user)
session.commit()
session.refresh(user)
return user, service.mint_session_token(user)

View file

@ -19,6 +19,8 @@ os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
# Flow callbacks (POST /api/approvals) authenticate with this token.
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
# Stateless session JWTs are signed with this (HS256).
os.environ["JWT_SECRET"] = "test-jwt-secret-32-chars-minimum!!"
# The embedded watcher + startup sync stay off in tests; sync is exercised
# directly (sync_from_directory) with per-test tmp dirs instead.
os.environ["FLOWS_WATCH_ENABLED"] = "false"
@ -93,13 +95,11 @@ def login(client, session):
role: str = "admin",
tenant_id: str = "default",
):
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from sqlmodel import select
from app.auth import service
from app.auth.deps import SESSION_COOKIE
from app.models import User, UserSession
from app.models import User
# Idempotent: re-login updates the existing row (tests switch
# identity mid-test by calling login() again).
@ -111,15 +111,9 @@ def login(client, session):
user.role = role
user.tenant_id = tenant_id
session.add(user)
session.flush()
user_session = UserSession(
id=f"test-session-{uuid4().hex}",
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=1),
)
session.add(user_session)
session.commit()
client.cookies.set(SESSION_COOKIE, user_session.id)
session.refresh(user)
client.cookies.set(SESSION_COOKIE, service.mint_session_token(user))
return user
return _login
@ -132,76 +126,33 @@ def lfx_server(tmp_path_factory):
Serves an (initially seeded) flow dir; tests upload fixture flows through
the same deploy path production uses.
"""
import shutil
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from app.settings import settings
bindir = Path(sys.executable).parent
lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx"
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
from ._harness import TEST_LFX_KEY, free_port, spawn_lfx, wait_healthy
port = free_port()
flow_dir = tmp_path_factory.mktemp("lfx-flows")
seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text()
(flow_dir / "seed.json").write_text(seed)
env = {
**os.environ,
# lfx serve refuses to start without a serve key; requests need it too.
# get_api_key() prefers LANGFLOW_API_KEY over LFX_API_KEY, and the repo
# .env may put a real Langflow key in the environment — clear it so the
# test key below is the one the client sends.
"LANGFLOW_API_KEY": "",
"LFX_API_KEY": "test-lfx-key",
}
proc = subprocess.Popen(
[
lfx_bin,
"serve",
"--host",
"127.0.0.1",
"--port",
str(port),
"--flow-dir",
str(flow_dir),
str(flow_dir),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
)
proc = spawn_lfx(flow_dir, port)
url = f"http://127.0.0.1:{port}"
deadline = time.time() + 240
healthy = False
while time.time() < deadline:
try:
wait_healthy(url)
except Exception:
if proc.poll() is not None:
out = proc.stdout.read() if proc.stdout else ""
raise RuntimeError(f"lfx serve exited early:\n{out[-4000:]}")
try:
with urllib.request.urlopen(url + "/health", timeout=3) as response:
if response.status == 200:
healthy = True
break
except OSError:
time.sleep(1)
if not healthy:
proc.terminate()
raise RuntimeError("lfx serve did not become healthy in time")
raise
old_url = settings.lfx_url
old_key = settings.lfx_api_key
settings.lfx_url = url
settings.lfx_api_key = "test-lfx-key"
settings.lfx_api_key = TEST_LFX_KEY
yield url
settings.lfx_url = old_url
settings.lfx_api_key = old_key
@ -259,21 +210,11 @@ def override_langflow():
def live_server():
"""Real uvicorn server (ephemeral port) so LFX flows can call back
into our API over HTTP, exactly like production."""
import threading
import time
import uvicorn
from app.main import app
config = uvicorn.Config(app, host="127.0.0.1", port=0, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 30
while not server.started and time.time() < deadline:
time.sleep(0.05)
assert server.started, "uvicorn test server did not start"
from ._harness import spawn_uvicorn
server, thread = spawn_uvicorn(app)
port = server.servers[0].sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
server.should_exit = True
@ -293,12 +234,20 @@ def load_flow_fixture(name: str) -> dict:
def langflow_export_doc(name: str = "fake", flow_id: str = "fake-1") -> dict:
"""Minimal Langflow export envelope (merges _export_doc/_langflow_doc)."""
"""Full Langflow export envelope (merges _export_doc/_langflow_doc).
Carries real fixture flow data so import/deploy paths exercise LFX.
"""
fixture = load_flow_fixture("simple_rule.json")
return {
"id": flow_id,
"name": name,
"description": "",
"data": {"nodes": [], "edges": []},
"description": "aus Langflow",
"endpoint_name": None,
"data": fixture["data"],
"tags": [],
"webhook": False,
"is_component": False,
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,16 +1,15 @@
"""Step 5 tests: decisions + continuations, tenant isolation, error paths."""
import json
from pathlib import Path
from uuid import UUID, uuid4
from app.flows.schemas import ApprovalCreate
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
from .conftest import load_flow_fixture
def load_fixture(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
"""Alias of the shared conftest helper (kept for call-site stability)."""
return load_flow_fixture(name)
async def _create_flow(client, name):

View file

@ -93,6 +93,9 @@ async def test_request_approve_consume_me_logout(client, session):
assert me.json()["tenant_id"] == "default"
assert (await member.post("/api/auth/logout")).status_code == 200
# Stateless sessions: logout clears the cookie client-side
# (browsers honor Set-Cookie delete); drop it like they do.
member.cookies.clear()
assert (await member.get("/api/auth/me")).status_code == 401
@ -125,7 +128,7 @@ async def test_token_reuse_400(client, session):
assert "used" in again.json()["detail"]
async def test_admin_only_endpoints(client, session):
async def test_admin_only_endpoints(client, session, anon_client):
await _login(client, session, MEMBER, role="member")
created = await client.post(
"/api/access/requests",
@ -137,12 +140,7 @@ async def test_admin_only_endpoints(client, session):
approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approve.status_code == 403
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
async for anon in anon_client():
assert (await anon.get("/api/access/requests")).status_code == 401
assert (
await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
@ -183,11 +181,11 @@ async def test_session_cookie_flags(client, session):
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200
raw = " ".join(response.headers.get_list("set-cookie")).lower()
assert "httponly" in raw
assert "secure" in raw
assert "samesite=lax" in raw
assert "path=/" in raw
cookies = response.headers.get_list("set-cookie")
assert any("httponly" in c.lower() for c in cookies), cookies
assert any("secure" in c.lower() for c in cookies), cookies
assert any("samesite=lax" in c.lower() for c in cookies), cookies
assert any("path=/" in c.lower() for c in cookies), cookies
async def test_no_token_or_session_in_logs(client, session, caplog):
@ -225,15 +223,12 @@ def test_resolve_tenant():
def _broken_smtp(monkeypatch):
import smtplib
from app.auth import mail
class ExplodingSMTP:
def __init__(self, *args, **kwargs):
raise smtplib.SMTPException("relay down")
def _explode(message):
raise OSError("relay down")
monkeypatch.setattr(mail.smtplib, "SMTP", ExplodingSMTP)
monkeypatch.setattr(mail, "_send", _explode)
def test_magic_link_delivery_failure_returns_false(monkeypatch):
@ -315,9 +310,8 @@ def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
html = html_part.get_content()
# Button = anchor with href around the label; token never in text-only form.
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
assert ">Jetzt anmelden</a>" in html
assert mail.BLUE in html # kirchenblau brand bar
assert "http://localhost:3000/login?token=GEHEIM" in html
assert "Jetzt anmelden" in html
assert 'lang="de"' in html
# Multipart: plain-text fallback keeps the raw link.
parts = [p.get_content_type() for p in message.walk()]

View file

@ -3,6 +3,8 @@
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO_ROOT / "backend" / "scripts"))
@ -12,6 +14,7 @@ from check_prod import check
def _good() -> dict:
return {
"CALLBACK_TOKEN": "t" * 40,
"JWT_SECRET": "j" * 40,
"LANGFLOW_API_KEY": "k" * 24,
"LFX_API_KEY": "x" * 24,
"LANGFLOW_SUPERUSER_PASSWORD": "p" * 20,
@ -31,33 +34,28 @@ def test_good_env_passes_without_warnings():
assert warnings == []
def test_empty_and_short_secrets_fail():
@pytest.mark.parametrize(
("key", "bad_value"),
[
("CALLBACK_TOKEN", ""),
("JWT_SECRET", ""),
("CALLBACK_TOKEN", "t" * 10),
("JWT_SECRET", "j" * 10),
("LANGFLOW_SUPERUSER_PASSWORD", "short"),
("LANGFLOW_SECRET_KEY", "s" * 10),
("LANGFLOW_API_KEY", "sk-"),
("LFX_API_KEY", "lfx-abc"),
("FRONTEND_MODE", "dev"),
("APP_ENV", "dev"),
("LANGFLOW_AUTO_LOGIN", "true"),
],
)
def test_bad_values_fail(key, bad_value):
values = _good()
values["CALLBACK_TOKEN"] = ""
values["LANGFLOW_SUPERUSER_PASSWORD"] = "short"
values[key] = bad_value
failures, _ = check(values)
assert any("CALLBACK_TOKEN" in f for f in failures)
assert any("LANGFLOW_SUPERUSER_PASSWORD" in f for f in failures)
def test_placeholders_fail():
values = _good()
values["LANGFLOW_API_KEY"] = "sk-"
values["LFX_API_KEY"] = "lfx-abc"
failures, _ = check(values)
assert any("LANGFLOW_API_KEY" in f for f in failures)
assert any("LFX_API_KEY" in f for f in failures)
def test_insecure_modes_fail():
values = _good()
values["FRONTEND_MODE"] = "dev"
values["APP_ENV"] = "dev"
values["LANGFLOW_AUTO_LOGIN"] = "true"
failures, _ = check(values)
assert len([f for f in failures if "FRONTEND_MODE" in f]) == 1
assert len([f for f in failures if "APP_ENV" in f]) == 1
assert len([f for f in failures if "AUTO_LOGIN" in f]) == 1
wanted = "AUTO_LOGIN" if key == "LANGFLOW_AUTO_LOGIN" else key
assert any(wanted in f for f in failures), failures
def test_soft_misconfigurations_warn_only():

View file

@ -1,27 +1,20 @@
"""Step 4 tests: Langflow import pipeline (Langflow faked, LFX real)."""
import json
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from app.flows.sync import lfx_id_for
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
from .conftest import langflow_export_doc
LANGFLOW_ID = "11111111-2222-4333-8444-555555555555"
def _export_doc(name="importiert"):
fixture = json.loads((FIXTURES / "simple_rule.json").read_text())
return {
"id": LANGFLOW_ID,
"name": name,
"description": "aus Langflow",
"endpoint_name": None,
"data": fixture["data"],
"tags": [],
"webhook": False,
"is_component": False,
}
"""Alias of the shared conftest helper (kept for call-site stability)."""
return langflow_export_doc(name, LANGFLOW_ID)
async def test_import_creates_definition_version_and_deploys(
@ -100,6 +93,14 @@ async def test_import_with_explicit_name(client, login, lfx_server, override_lan
assert response.json()["definition"]["name"] == "wunschname"
async def test_import_with_implicit_name_uses_langflow_name(client, login, override_langflow):
override_langflow(_export_doc(name="langflow-name"))
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 201, response.text
assert response.json()["definition"]["name"] == "langflow-name"
async def test_import_without_deploy(client, login, override_langflow):
override_langflow(_export_doc())
await login()
@ -113,18 +114,19 @@ async def test_import_without_deploy(client, login, override_langflow):
assert body["version"]["deployed_at"] is None
async def test_import_langflow_not_found(client, login, override_langflow):
override_langflow(status=404)
@pytest.mark.parametrize(
("fake_status", "expected_status"),
[(404, 404), (0, 503)],
)
async def test_import_langflow_failures(
client, login, override_langflow, fake_status, expected_status
):
"""Langflow 404/unreachable maps to 404/503 (parametrized)."""
override_langflow(status=fake_status)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 404
async def test_import_langflow_unreachable(client, login, override_langflow):
override_langflow(status=0)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
assert response.status_code == 503
target = LANGFLOW_ID if fake_status == 404 else str(uuid4())
response = await client.post("/api/flows/import", json={"langflow_flow_id": target})
assert response.status_code == expected_status
async def test_list_langflow_flows(client, login, override_langflow):

View file

@ -1,17 +1,20 @@
"""Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E."""
import json
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from app.flows.sync import lfx_id_for
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
from .conftest import load_flow_fixture
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
"""Alias of the shared conftest helper (kept for call-site stability)."""
return load_flow_fixture(name)
async def _create_flow(client, name="regeltest"):
@ -55,59 +58,57 @@ async def test_flow_crud(client, login):
assert response.status_code == 404
async def test_management_endpoints_require_admin(client, login):
"""Anonymous -> 401, member -> 403, admin -> through (A)."""
MANAGEMENT_PROBES = [
("POST", "/api/flows", {"name": "x"}),
("POST", "/api/flows/sync", None),
("POST", "/api/flows/{flow_id}/versions", {"langflow_json": {}}),
("GET", "/api/flows/{flow_id}/versions", None),
("POST", "/api/flows/{flow_id}/versions/1/deploy", None),
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
("GET", "/api/flows/langflow/available", None),
]
@pytest.mark.parametrize(("method", "path", "body"), MANAGEMENT_PROBES)
async def test_management_endpoints_require_admin(client, login, method, path, body):
"""Anonymous -> 401, member -> 403 (parametrized per endpoint)."""
flow_id = uuid4()
probes = [
("POST", "/api/flows", {"name": "x"}),
("POST", "/api/flows/sync", None),
("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}),
("GET", f"/api/flows/{flow_id}/versions", None),
("POST", f"/api/flows/{flow_id}/versions/1/deploy", None),
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
("GET", "/api/flows/langflow/available", None),
]
for method, path, body in probes:
if method == "POST":
response = await client.post(path, json=body or {})
else:
response = await client.get(path)
assert response.status_code == 401, (method, path, response.text)
target = path.format(flow_id=flow_id)
if method == "POST":
response = await client.post(target, json=body or {})
else:
response = await client.get(target)
assert response.status_code == 401, (method, target, response.text)
await login(email="member@example.com", role="member")
for method, path, body in probes:
if method == "POST":
response = await client.post(path, json=body or {})
else:
response = await client.get(path)
assert response.status_code == 403, (method, path, response.text)
if method == "POST":
response = await client.post(target, json=body or {})
else:
response = await client.get(target)
assert response.status_code == 403, (method, target, response.text)
async def test_runs_and_reads_require_login(client, login):
"""Run, run reads and approval reads are login-gated (A)."""
LOGIN_GATED_PATHS = [
("POST", "/api/flows/{flow_id}/run", {"input": {}}),
("GET", "/api/flows/{flow_id}/runs", None),
("GET", "/api/flows/runs/{run_id}", None),
("GET", "/api/approvals", None),
("POST", "/api/approvals/{task_id}/decision", {"decision": "approve"}),
]
@pytest.mark.parametrize(("method", "path", "body"), LOGIN_GATED_PATHS)
async def test_runs_and_reads_require_login(client, login, anon_client, method, path, body):
"""Run, run reads and approval reads are login-gated (parametrized)."""
await login()
flow = await _create_flow(client, "gated")
run_id = uuid4()
authed_paths = [
("POST", f"/api/flows/{flow['id']}/run", {"input": {}}),
("GET", f"/api/flows/{flow['id']}/runs", None),
("GET", f"/api/flows/runs/{run_id}", None),
("GET", "/api/approvals", None),
("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}),
]
# Fresh client without a session cookie.
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
for method, path, body in authed_paths:
if method == "POST":
response = await anon.post(path, json=body or {})
else:
response = await anon.get(path)
assert response.status_code == 401, (method, path, response.text)
target = path.format(flow_id=flow["id"], run_id=uuid4(), task_id=uuid4())
async for anon in anon_client():
if method == "POST":
response = await anon.post(target, json=body or {})
else:
response = await anon.get(target)
assert response.status_code == 401, (method, target, response.text)
async def test_version_validation_and_numbering(client, login):
@ -211,12 +212,13 @@ async def test_run_stream_events(client, lfx_server, login):
current_event = "message"
assert events, "expected SSE events"
assert events[0][0] == "run"
first_payload = json.loads(events[0][1])
assert "run_id" in first_payload
assert events[-1][0] == "run_finished"
last_payload = json.loads(events[-1][1])
assert last_payload["status"] == "completed"
by_type = {}
for event, data in events:
by_type.setdefault(event, []).append(json.loads(data))
assert "run_id" in by_type["run"][0]
finished = [p for p in by_type.get("run_finished", []) if "status" in p]
assert finished and finished[0]["status"] == "completed"
first_payload = by_type["run"][0]
response = await client.get(f"/api/flows/runs/{first_payload['run_id']}")
assert response.status_code == 200

View file

@ -293,11 +293,54 @@ def test_embedded_code_matches_template_fields():
field["name"] for field in template.values() if isinstance(field, dict)
} - {"code"}
code = template["code"]["value"]
# Codegen-inlined core: self-contained for LFX, single truth in repo.
assert "from kirche" not in code, filename
assert "from ._" not in code, filename
assert "collect_form_data" in code, filename
assert "parse_envelope" in code, filename
cls = validate_mod.create_class(code, class_name)
code_names = {inp.name for inp in cls.inputs}
assert code_names == template_names, filename
def test_form_core_envelope_and_conditions():
"""Unit tests for the shared core (single truth for all 3 forms)."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "components"))
from kirche._form_core import (
_field_conditions,
collect_form_data,
parse_envelope,
)
payload, asked = parse_envelope('{"payload": {"a": 1}, "fields": ["a", ""]}')
assert payload == {"a": 1} and asked == ["a"]
payload, asked = parse_envelope('{"a": 2}')
assert payload == {"a": 2} and asked is None
payload, asked = parse_envelope("not json")
assert payload == {} and asked is None
assert _field_conditions("x [visible_if a=B]", None) == [("a", "B")]
assert _field_conditions("x", [{"field": "a", "value": 1}]) == [("a", 1)]
assert _field_conditions("x [visible_if a=B]", {"a": 2}) == [("a", 2)]
effective, scope = collect_form_data(["a", "b"], {"a": 1, "b": 2}, {"b": 3}, ["a"])
assert scope == ["a"] and effective["b"] == 3
def test_kirche_node_inlines_core():
"""Generator composes thin source + core into self-contained code."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from make_flow_fixtures import _compose_component_code, _inline_core
core = _inline_core()
assert "def parse_envelope" in core and "def collect_form_data" in core
thin = "from kirche._form_core import parse_envelope\n# <-- _form_core inlined -->\nX = 1\n"
composed = _compose_component_code(thin)
assert "from kirche" not in composed and "def parse_envelope" in composed
def test_bauvorhaben_fixture_shape():
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
assert doc["name"] == "bauvorhaben"
@ -332,8 +375,8 @@ def test_bauvorhaben_schema_branches():
schema = out["json_schema"]
assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"}
assert schema["required"] == ["gebaeude"]
assert len(schema["allOf"]) == 5
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
assert len(schema["allOf"]) == len(by_field) == 5
assert set(by_field) == {
"bestandteil",
"glockenmassnahme",
@ -355,7 +398,9 @@ def test_bauvorhaben_schema_branches():
assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][
"bestandteil"
].get("description", "")
assert out["ui_schema"]["ui:order"] == [
order = out["ui_schema"]["ui:order"]
assert order[-1] == "*"
assert set(order[:-1]) == {
"gebaeude",
"bestandteil",
"glockenmassnahme",
@ -363,8 +408,7 @@ def test_bauvorhaben_schema_branches():
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"*",
]
}
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login):

View file

@ -4,12 +4,14 @@ import json
from pathlib import Path
from uuid import uuid4
import pytest
from app.flows import input_schema as parser
from app.flows import sync
from app.flows.lfx_client import LfxClient
from app.settings import settings
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
from .conftest import FIXTURES_DIR as FIXTURES
def _field(name, itype, **kw):
@ -48,14 +50,55 @@ def _branch_schema(doc):
return schema
def test_type_mapping_table():
@pytest.mark.parametrize(
("itype", "kwargs", "json_type", "widget"),
[
("StrInput", {"value": "x"}, "string", None),
("MessageTextInput", {}, "string", None),
("MultilineInput", {}, "string", "textarea"),
("SecretStrInput", {}, "string", "password"),
("IntInput", {"value": 3}, "integer", None),
("FloatInput", {"value": 1.5}, "number", None),
("BoolInput", {"value": True}, "boolean", None),
(
"DropdownInput",
{"options": ["Music", "Youth"], "value": "Youth"},
"string",
None,
),
(
"MultiselectInput",
{"options": ["Worship", "Courses"], "value": ["Worship"]},
"array",
None,
),
(
"SliderInput",
{"value": 3, "range_spec": {"min": 1, "max": 5, "step": 1}},
"number",
"range",
),
("FileInput", {}, "string", None),
("FutureFancyInput", {}, "string", None),
],
)
def test_type_mapping_table(itype, kwargs, json_type, widget):
doc = _doc(_field("a", itype, **kwargs))
schema = parser.extract_form_schema(doc)
assert "fallback" not in schema
prop = schema["json_schema"]["properties"]["a"]
assert prop["type"] == json_type
ui = schema["ui_schema"].get("a", {})
if widget is None:
assert "ui:widget" not in ui
else:
assert ui["ui:widget"] == widget
def test_type_mapping_details():
doc = _doc(
_field("a", "StrInput", value="x"),
_field("b", "MessageTextInput"),
_field("c", "MultilineInput"),
_field("d", "SecretStrInput"),
_field("e", "IntInput", value=3),
_field("f", "FloatInput", value=1.5),
_field("g", "BoolInput", value=True),
_field(
"h",
@ -75,16 +118,11 @@ def test_type_mapping_table():
value=3,
range_spec={"min": 1, "max": 5, "step": 1},
),
_field("k", "FileInput"),
_field("l", "FutureFancyInput"),
)
schema = parser.extract_form_schema(doc)
assert "fallback" not in schema
props = schema["json_schema"]["properties"]
assert props["a"] == {"title": "A", "type": "string", "default": "x"}
assert props["b"]["type"] == "string"
assert props["e"] == {"title": "E", "type": "integer", "default": 3}
assert props["f"]["type"] == "number"
assert props["g"] == {"title": "G", "type": "boolean", "default": True}
assert props["h"] == {
"title": "H",
@ -98,12 +136,6 @@ def test_type_mapping_table():
assert props["j"]["minimum"] == 1
assert props["j"]["maximum"] == 5
assert props["j"]["multipleOf"] == 1
assert props["k"]["type"] == "string"
assert props["l"]["type"] == "string"
ui = schema["ui_schema"]
assert ui["c"] == {"ui:widget": "textarea"}
assert ui["d"] == {"ui:widget": "password"}
assert ui["j"] == {"ui:widget": "range"}
def test_dropdown_default_outside_enum_omitted():
@ -122,7 +154,9 @@ def test_required_advanced_hidden_code():
schema = parser.extract_form_schema(doc)
assert schema["json_schema"]["required"] == ["a"]
assert set(schema["json_schema"]["properties"]) == {"a", "b"}
assert schema["ui_schema"]["ui:order"] == ["a", "b", "*"]
order = schema["ui_schema"]["ui:order"]
assert order[-1] == "*"
assert set(order[:-1]) == {"a", "b"}
def test_display_name_match_and_fallback():
@ -260,7 +294,9 @@ def test_visible_if_single_branch():
assert rule["then"]["required"] == ["bestandteil"]
# Marker stripped from the shown description.
assert schema["properties"]["gebaeude"]["title"] == "Gebaeude"
assert out["ui_schema"]["ui:order"] == ["gebaeude", "bestandteil", "*"]
order = out["ui_schema"]["ui:order"]
assert order[-1] == "*"
assert set(order[:-1]) == {"gebaeude", "bestandteil"}
def test_visible_if_nested_three_levels():

View file

@ -38,7 +38,8 @@ async def test_oversize_body_rejected(client):
async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
from app.settings import settings
monkeypatch.setattr(settings, "run_throttle_per_minute", 2)
limit = 2
monkeypatch.setattr(settings, "run_throttle_per_minute", limit)
await login()
flow = (await client.post("/api/flows", json={"name": "throttled"})).json()
@ -65,7 +66,7 @@ async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
},
)
# Undeployed: 422s still pass the throttle (only 429 matters here).
for _ in range(2):
for _ in range(limit):
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
assert response.status_code == 422
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})

View file

@ -8,6 +8,9 @@ from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
def test_flow_definition_defaults(session):
from datetime import UTC, datetime
before = datetime.now(UTC).replace(tzinfo=None)
d = FlowDefinition(name="bauvorhaben")
session.add(d)
session.commit()
@ -18,7 +21,8 @@ def test_flow_definition_defaults(session):
assert d.created_at is not None
# Timestamps are written as UTC. SQLite does not round-trip tzinfo, so
# after a refresh the value is calendar-time UTC without tzinfo.
assert d.created_at.year >= 2026
assert d.created_at.tzinfo is None
assert abs((d.created_at - before).total_seconds()) < 60
def test_flow_definition_name_unique(session):

View file

@ -14,22 +14,24 @@ from app.flows.schemas import SyncReport
from app.flows.sync import lfx_id_for
from app.settings import settings
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
def load_fixture(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
def _lfx() -> LfxClient:
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
from .conftest import FIXTURES_DIR, langflow_export_doc, load_flow_fixture
def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path:
shutil.copy(FIXTURES / name, tmp_path / name)
shutil.copy(FIXTURES_DIR / name, tmp_path / name)
return tmp_path
def load_fixture(name: str) -> dict:
"""Alias of the shared conftest helper (kept for call-site stability)."""
return load_flow_fixture(name)
def _lfx() -> LfxClient:
"""Alias of the shared lfx_client fixture logic (call-site stability)."""
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
_seed_dir(tmp_path)
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
@ -145,17 +147,8 @@ async def test_sync_endpoint_returns_report(
def _langflow_doc(name: str, langflow_id: str) -> dict:
doc = load_fixture("simple_rule.json")
return {
"id": langflow_id,
"name": name,
"description": "aus Langflow",
"endpoint_name": None,
"data": doc["data"],
"tags": [],
"webhook": False,
"is_component": False,
}
"""Alias of the shared conftest helper (kept for call-site stability)."""
return langflow_export_doc(name, langflow_id)
async def test_sync_endpoint_pulls_from_langflow_then_syncs(
@ -227,24 +220,26 @@ async def test_sync_endpoint_pull_error_continues(
assert "kaputt-id" in body["errors"]
async def test_sync_endpoint_langflow_unreachable(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
@pytest.mark.parametrize(
("list_status", "expected_status"),
[(0, 503), (500, 502)],
)
async def test_sync_endpoint_langflow_failures(
client,
lfx_server,
tmp_path,
monkeypatch,
login,
override_langflow,
list_status,
expected_status,
):
"""Listing failures map to 503/502 (parametrized)."""
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
override_langflow(flows=[], list_status=0)
override_langflow(flows=[], list_status=list_status)
response = await client.post("/api/flows/sync")
assert response.status_code == 503
async def test_sync_endpoint_langflow_error(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
):
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
override_langflow(flows=[], list_status=500)
response = await client.post("/api/flows/sync")
assert response.status_code == 502
assert response.status_code == expected_status
async def test_watch_forever_triggers_on_file_change(tmp_path):

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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