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 # Shared secret for flow callbacks (POST /api/approvals carries it as
# X-Callback-Token). Long random value; empty disables callbacks. # X-Callback-Token). Long random value; empty disables callbacks.
CALLBACK_TOKEN= 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 --- # --- frontend mode: unset/preview serves the production bundle ---
# Dev sets FRONTEND_MODE=dev (HMR); .devcontainer/setup.sh provisions it. # 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] ## [Unreleased]
### Changed ### 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`. - 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.
- 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). - 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`.
- 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`. - 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).
- Frontend: shared `formatApiError`, `<ErrorAlert/>`, `<RunsTable/>`, `URLSearchParams` queries, localized run dates. - 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 ### Added
- Aggregate `GET /api/runs` (tenant-scoped runs with flow names, single query).
- Branched questionnaires: `[visible_if feld=Wert]` markers in a - Branched questionnaires: `[visible_if feld=Wert]` markers in a
`CustomInputForm` field's `info` (code editor) show the field only when `CustomInputForm` field's `info` (code editor) show the field only when
the condition holds — nested to any depth, with `&` for AND conditions 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)], session: Annotated[Session, Depends(get_db)],
): ):
try: try:
user_session = service.consume_magic_token(session, data.token) user, session_token = service.consume_magic_token(session, data.token)
except ( except (
service.InvalidTokenError, service.InvalidTokenError,
service.ExpiredTokenError, service.ExpiredTokenError,
service.UsedTokenError, service.UsedTokenError,
) as exc: ) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
user = service.get_user(session, user_session.user_id) _set_session_cookie(response, session_token)
if user is None: # pragma: no cover - consume already validated the user
raise HTTPException(status_code=400, detail="unknown token")
_set_session_cookie(response, user_session.id)
return _read_me(user) return _read_me(user)
@ -151,11 +148,8 @@ def logout(
response: Response, response: Response,
session: Annotated[Session, Depends(get_db)], session: Annotated[Session, Depends(get_db)],
): ):
# Revoke the server-side row when the cookie maps to a live session; # Stateless sessions: nothing server-side to revoke — always clear
# always clear the client cookie. # the client cookie.
session_id = request.cookies.get(SESSION_COOKIE)
if session_id:
service.revoke_session(session, session_id)
response.delete_cookie(SESSION_COOKIE, path="/") response.delete_cookie(SESSION_COOKIE, path="/")
return MessageRead(message="logged out") return MessageRead(message="logged out")

View file

@ -19,13 +19,10 @@ SESSION_MAX_AGE = int(service.SESSION_TTL.total_seconds())
def get_optional_user( def get_optional_user(
request: Request, session: Annotated[Session, Depends(get_db)] request: Request, session: Annotated[Session, Depends(get_db)]
) -> User | None: ) -> User | None:
session_id = request.cookies.get(SESSION_COOKIE) token = request.cookies.get(SESSION_COOKIE)
if not session_id: if not token:
return None return None
record = service.get_valid_session(session, session_id) return service.get_token_user(session, token)
if record is None:
return None
return service.get_user(session, record.user_id)
def get_current_user(request: Request, session: Annotated[Session, Depends(get_db)]) -> User: 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 from __future__ import annotations
import logging import logging
import smtplib
from email.message import EmailMessage from email.message import EmailMessage
from pathlib import Path
from jinja2 import Template
from app.settings import settings from app.settings import settings
@ -22,67 +24,62 @@ BLUE_LIGHT = "#eef4fb"
INK = "#162f4c" INK = "#162f4c"
MUTED = "#5b6b7c" 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: def magic_link(token: str) -> str:
return f"{settings.frontend_url.rstrip('/')}/login?token={token}" 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: 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).""" """Branded HTML mail body (jinja template, byte-identical to the old f-string)."""
button = "" return _template().render(
if button_url: title=title,
button = ( intro=intro,
'<table role="presentation" cellpadding="0" cellspacing="0" ' button_label=button_label,
'style="margin:24px auto 8px auto;"><tr>' button_url=button_url,
f'<td bgcolor="{BLUE}" style="border-radius:8px;">' note=note,
f'<a href="{button_url}" ' blue=BLUE,
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,' blue_light=BLUE_LIGHT,
"Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;" ink=INK,
"font-weight:600;color:#ffffff;text-decoration:none;" muted=MUTED,
f'border-radius:8px;">{button_label}</a></td></tr></table>' )
)
return f"""<!DOCTYPE html>
<html lang="de">
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
<tr>
<td bgcolor="{BLUE}" style="padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;
letter-spacing:0.02em;">Kirche Einfach</span>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
color:{INK};">{title}</h1>
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
color:{MUTED};">{intro}</p>
{button}
</td>
</tr>
</table>
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
{note}
</p>
</td></tr>
</table>
</body>
</html>"""
def _send(message: EmailMessage) -> None: def _send(message: EmailMessage) -> None:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: """Deliver via fastapi-mail (same never-raise contract at call sites)."""
smtp.starttls() import asyncio
if settings.smtp_username:
smtp.login(settings.smtp_username, settings.smtp_password) from fastapi_mail import ConnectionConfig, FastMail, MessageSchema, MessageType
smtp.send_message(message)
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( def _branded_mail(
@ -142,7 +139,7 @@ def send_magic_link(to_email: str, link: str) -> bool:
) )
try: try:
_send(message) _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) logger.warning("magic-link delivery failed for %s", to_email)
return False return False
logger.info("magic link mailed to %s", to_email) 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: try:
_send(message) _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) logger.warning("access-request notification failed for %s", request_id)
return False return False
return True 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 Keep API handlers thin: all persistence lives here. Tokens and session
are bearer secrets — log user/request ids only, never secret values. 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 from __future__ import annotations
@ -9,12 +14,14 @@ from __future__ import annotations
import hashlib import hashlib
import logging import logging
import secrets import secrets
import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
import jwt
from sqlmodel import Session, func, select 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.models.base import utcnow
from app.settings import settings from app.settings import settings
@ -235,8 +242,43 @@ def _aware(value: datetime) -> datetime:
return value return value
def consume_magic_token(session: Session, token: str) -> UserSession: def _jwt_secret() -> str:
"""Single-use consume → session row. used_at is set before creation.""" 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( record = session.exec(
select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip())) select(MagicLinkToken).where(MagicLinkToken.token_hash == hash_token(token.strip()))
).first() ).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. # Mark used first: a reused token can never mint a second session.
record.used_at = utcnow() record.used_at = utcnow()
session.add(record) 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.commit()
session.refresh(user_session) session.refresh(user)
logger.info("magic token consumed for user %s", user.id) 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: def get_token_user(session: Session, token: str) -> User | None:
record = session.get(UserSession, session_id) """User for a session JWT, or None (expired/forged/deleted user)."""
if record is None or record.revoked_at is not None: try:
claims = decode_session_token(token)
except (InvalidTokenError, ExpiredTokenError):
return None return None
if _aware(record.expires_at) <= utcnow(): try:
user_id = UUID(str(claims.get("sub", "")))
except ValueError:
return None return None
return record return session.get(User, user_id)
def revoke_session(session: Session, session_id: str) -> None:
record = session.get(UserSession, session_id)
if record is not None and record.revoked_at is None:
record.revoked_at = utcnow()
session.add(record)
session.commit()
def bootstrap_admin(session: Session, email: str) -> tuple[User, str]: 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 typing import Annotated
from uuid import UUID 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 fastapi.responses import StreamingResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user, require_admin from app.auth.deps import get_current_user, require_admin
from app.db import get_db from app.db import get_db
from app.middleware import limiter, run_limit
from app.models import User from app.models import User
from app.settings import settings from app.settings import settings
@ -50,6 +51,7 @@ from .schemas import (
FlowVersionCreate, FlowVersionCreate,
FlowVersionRead, FlowVersionRead,
InputSchemaRead, InputSchemaRead,
NamedRunRead,
RunRead, RunRead,
RunRequest, RunRequest,
SyncReport, SyncReport,
@ -58,6 +60,7 @@ from .sync import lfx_id_for
flows_router = APIRouter(prefix="/api/flows", tags=["flows"]) flows_router = APIRouter(prefix="/api/flows", tags=["flows"])
approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"]) approvals_router = APIRouter(prefix="/api/approvals", tags=["approvals"])
runs_router = APIRouter(prefix="/api/runs", tags=["runs"])
logger = logging.getLogger(__name__) 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) @flows_router.post("/{flow_id}/run", response_model=RunRead)
@limiter.limit(run_limit)
async def run_flow( async def run_flow(
request: Request,
flow_id: UUID, flow_id: UUID,
data: RunRequest, data: RunRequest,
session: Annotated[Session, Depends(get_db)], session: Annotated[Session, Depends(get_db)],
@ -279,6 +284,18 @@ async def _run_stream(
return StreamingResponse(event_gen(), media_type="text/event-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]) @flows_router.get("/{flow_id}/runs", response_model=list[RunRead])
def list_runs( def list_runs(
flow_id: UUID, flow_id: UUID,

View file

@ -2,12 +2,19 @@
Consolidates base_url stripping, header building, per-request client Consolidates base_url stripping, header building, per-request client
construction, and HTTPError → domain-error mapping. Subclasses only 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 from __future__ import annotations
import httpx import httpx
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
class BaseApiClient: class BaseApiClient:
@ -25,3 +32,13 @@ class BaseApiClient:
return httpx.AsyncClient( return httpx.AsyncClient(
base_url=self.base_url, timeout=self.timeout, headers=self._headers() 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 json
import os import os
import re import re
import tempfile
from pathlib import Path from pathlib import Path
FLOWS_GLOB = "*.json" FLOWS_GLOB = "*.json"
@ -20,18 +19,32 @@ FLOWS_GLOB = "*.json"
#: validated, or shown in the frontend. #: validated, or shown in the frontend.
IGNORE_FILENAME = ".flowsignore" 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]: def ignored_stems(flows_dir: str | Path) -> set[str]:
"""Stems excluded from the pipeline (empty when no ignore file).""" """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: try:
text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8") text = (Path(flows_dir) / IGNORE_FILENAME).read_text(encoding="utf-8")
except OSError: except OSError:
return set() return set()
return { stems = {
line.strip() line.strip()
for line in text.splitlines() for line in text.splitlines()
if line.strip() and not line.strip().startswith("#") if line.strip() and not line.strip().startswith("#")
} }
_ignore_cache[key] = (mtime, stems)
return stems
def filename_stem(name: str) -> str: 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: def write_flow_bytes(target: Path, doc: dict) -> Path:
"""Write a flow document to an explicit path atomically (tmp + rename). """Write a flow document to an explicit path atomically (tmp + rename).
The file ends up world-readable (0o644): mkstemp creates 0o600, which The file ends up world-readable (0o644): atomicwrites follows the
would lock out the other containers (Langflow/LFX run as different umask, which would lock out the other containers (Langflow/LFX run
users and crash on unreadable seed files). as different users and crash on unreadable seed files).
""" """
from atomicwrites import atomic_write
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp( with atomic_write(target, mode="wb", overwrite=True) as tmp:
dir=str(target.parent), prefix=f".{target.stem}-", suffix=".tmp" tmp.write(canonical_bytes(doc))
) os.chmod(target, 0o644)
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
return target return target

View file

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

View file

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

View file

@ -76,6 +76,13 @@ class RunRead(BaseModel):
finished_at: datetime | None 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): class ApprovalCreate(BaseModel):
"""Callback contract: a flow reports a terminal approval state.""" """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") 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) get_definition(session, flow_id)
validate_flow_json(data.langflow_json) validate_flow_json(data.langflow_json)
version = FlowVersion( version = FlowVersion(
flow_id=flow_id, 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, langflow_json=data.langflow_json,
author=data.author, author=data.author,
# Single choke point: sync, import, and API creation all cache the # 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()) 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: def create_approval_task(session: Session, data: ApprovalCreate) -> ApprovalTask:
run = session.get(FlowRun, data.run_id) run = session.get(FlowRun, data.run_id)
if run is None: if run is None:

View file

@ -64,8 +64,8 @@ async def sync_document(
"skipped" (content unchanged). Raises store.ConflictError on name "skipped" (content unchanged). Raises store.ConflictError on name
clashes, store.InvalidError on invalid JSON, LfxError when deploy fails. clashes, store.InvalidError on invalid JSON, LfxError when deploy fails.
With deploy=False a version is recorded without touching LFX. 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 name = doc.get("name") or Path(source_filename).stem
with sync_lock: with sync_lock:
definition = store.get_definition_by_source_path(session, source_filename) definition = store.get_definition_by_source_path(session, source_filename)
@ -102,9 +102,8 @@ async def sync_document(
session, session,
definition.id, definition.id,
FlowVersionCreate(langflow_json=doc, author=author), 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) version = store.mark_deployed(session, version, uploaded.id)
if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id: if latest is not None and latest.lfx_flow_id and latest.lfx_flow_id != uploaded.id:
try: try:

View file

@ -4,13 +4,16 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path 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 starlette.middleware.body_limit import RequestBodyLimitMiddleware
from .auth.api import access_router, auth_router 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 .flows.sync import format_report, run_sync_blocking, watch_forever
from .middleware import RunThrottleMiddleware, SecurityHeadersMiddleware from .middleware import SecurityHeadersMiddleware, limiter
from .settings import settings from .settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -64,12 +67,23 @@ app = FastAPI(
# entrypoint.sh) trusts its forwarding headers so Secure cookies, client # entrypoint.sh) trusts its forwarding headers so Secure cookies, client
# IPs (throttle) and absolute URLs are correct. The API is not directly # IPs (throttle) and absolute URLs are correct. The API is not directly
# internet-reachable (proxy-only), so trusting them is safe. # 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(RequestBodyLimitMiddleware, max_body_size=settings.max_body_bytes)
app.add_middleware(RunThrottleMiddleware) app.add_middleware(SlowAPIMiddleware)
app.add_middleware(SecurityHeadersMiddleware) 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(flows_router)
app.include_router(approvals_router) app.include_router(approvals_router)
app.include_router(runs_router)
app.include_router(access_router) app.include_router(access_router)
app.include_router(auth_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 Body caps come from Starlette's RequestBodyLimitMiddleware (wired in
main.py). All limits are settings-driven so tests can shrink them. State main.py). The run throttle is settings-driven so tests can shrink it.
is in-process memory: correct for the single-replica SQLite deployment; State is in-process memory: correct for the single-replica SQLite
revisit with Redis if the API ever scales horizontally. deployment; revisit with Redis if the API ever scales horizontally.
""" """
from __future__ import annotations from __future__ import annotations
import time from slowapi import Limiter
from collections import defaultdict from slowapi.util import get_remote_address
from starlette.datastructures import MutableHeaders 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 starlette.types import ASGIApp, Message, Receive, Scope, Send
from app.settings import settings from app.settings import settings
# POST .../run is the expensive endpoint (LFX execution, LLM cost): # POST .../run is the expensive endpoint (LFX execution, LLM cost):
# per client IP and minute. # per client IP and minute, limit read live from settings so tests can
RUN_PATH_SUFFIX = "/run" # shrink it without re-creating the app.
THROTTLE_WINDOW_SECONDS = 60.0 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: def reset_throttle() -> None:
"""Drop all counters (tests).""" """Drop all counters (tests)."""
_hits.clear() try:
limiter._storage.reset() # type: ignore[attr-defined]
except AttributeError:
def _client_ip(request: Request) -> str: pass
# Behind the TLS proxy ProxyHeadersMiddleware has already rewritten
# scope["client"] from X-Forwarded-For.
if request.client is not None:
return request.client.host
forwarded = request.headers.get("x-forwarded-for", "")
return forwarded.split(",")[0].strip() or "unknown"
class RunThrottleMiddleware:
"""Per-IP sliding window on POST .../run (429 + Retry-After)."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if (
scope["type"] != "http"
or scope.get("method") != "POST"
or not scope.get("path", "").endswith(RUN_PATH_SUFFIX)
):
await self.app(scope, receive, send)
return
request = Request(scope)
now = time.monotonic()
window_start = now - THROTTLE_WINDOW_SECONDS
bucket = [t for t in _hits[_client_ip(request)] if t > window_start]
if len(bucket) >= settings.run_throttle_per_minute:
retry_after = max(1, int(bucket[0] + THROTTLE_WINDOW_SECONDS - now))
response = JSONResponse({"detail": "too many runs, try again later"}, status_code=429)
response.headers["Retry-After"] = str(retry_after)
await response(scope, receive, send)
return
bucket.append(now)
_hits[_client_ip(request)] = bucket
await self.app(scope, receive, send)
class SecurityHeadersMiddleware: 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: def __init__(self, app: ASGIApp) -> None:
import secure as secure_lib
self.app = app 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: async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http": if scope["type"] != "http":
@ -83,10 +63,8 @@ class SecurityHeadersMiddleware:
async def secured(message: Message) -> None: async def secured(message: Message) -> None:
if message["type"] == "http.response.start": if message["type"] == "http.response.start":
headers = MutableHeaders(raw=message.setdefault("headers", [])) headers = MutableHeaders(raw=message.setdefault("headers", []))
headers["X-Content-Type-Options"] = "nosniff" for key, value in self.secure_headers.headers.items():
headers["X-Frame-Options"] = "SAMEORIGIN" headers[key] = value
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
headers["Content-Security-Policy"] = "frame-ancestors 'self'"
await send(message) await send(message)
await self.app(scope, receive, secured) await self.app(scope, receive, secured)

View file

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

View file

@ -47,19 +47,7 @@ class MagicLinkToken(SQLModel, table=True):
user_id: UUID = Field(foreign_key="users.id", index=True) user_id: UUID = Field(foreign_key="users.id", index=True)
token_hash: str = Field(index=True, unique=True, max_length=64) token_hash: str = Field(index=True, unique=True, max_length=64)
expires_at: datetime = Field(sa_column=ts_column()) 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. # mint a second session.
used_at: datetime | None = Field(default=None, sa_column=ts_column()) used_at: datetime | None = Field(default=None, sa_column=ts_column())
created_at: datetime = Field(default_factory=utcnow, 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 run_throttle_per_minute: int = 60
# --- access management (magic-link auth, see app/auth) --- # --- 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" frontend_url: str = "http://localhost:3000"
# Comma-separated emails that become admins (role assignment + bootstrap). # Comma-separated emails that become admins (role assignment + bootstrap).
admin_emails: str = "" 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 The API watcher syncs + deploys it automatically; the form schema is
regenerated per version. 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 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 ```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 the output. Without the list — raw dicts, Langflow playground — all
visible declared defaults apply as before. 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 A flow whose fields differ from `CustomInputForm` gets its own thin
standalone component (see `bauvorhaven_form.py` → "Bauvorhaben Form" component (see `bauvorhaben_form.py` → "Bauvorhaben Form" in the
in the palette): same class pattern, own `inputs` list. Standalone palette): same class pattern, own `inputs` list, shared runtime from
means it: no imports between form modules — the LFX runtime executes `kirche._form_core` (`from kirche._form_core import ...` plus the
the embedded module source without our `kirche` package, so every form `# <-- _form_core inlined ... -->` marker). The LFX runtime executes
component must be self-contained (stdlib + `lfx` only). Copy the file the embedded module source without our `kirche` package, so
as the starting point for future forms. `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 Rule: code-declared inputs must equal template fields
(`test_embedded_code_matches_template_fields` enforces it; required (`test_embedded_code_matches_template_fields` enforces it, plus
Multiselect uses the `[" "]` sentinel). Generate node + flow with no-sibling-import + core-present asserts; required Multiselect uses
`kirche_node(..., component_cls, code_override)` and 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` `build_bauvorhaben()` in `backend/scripts/make_flow_fixtures.py`
rather than hand-editing JSON. Restart Langflow afterwards so the rather than hand-editing JSON. Restart Langflow afterwards so the
palette picks up the new component, then build/upload the flow. palette picks up the new component, then build/upload the flow.

View file

@ -1,11 +1,9 @@
"""Bauvorhaben form component for kirche-einfach flows. """Bauvorhaben form component for kirche-einfach flows.
Standalone by design: this module shares NO code with ``form_inputs`` Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
(no imports from it). The LFX runtime executes the embedded module for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
source without our ``kirche`` package, so every form component must be inlines the core into the exported ``code`` so the LFX runtime stays
self-contained (stdlib + ``lfx`` only). Do NOT refactor the duplicated self-contained. Copy this file as the starting point for future forms.
machinery below into a shared import — it would break flow execution.
Copy this file as the starting point for future forms.
A flow that starts with :class:`CustomInputBauvorhaben` declares the A flow that starts with :class:`CustomInputBauvorhaben` declares the
fields of its web form. The API reads the exported node template 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 from __future__ import annotations
import json import json # noqa: F401 -- used by inlined _form_core at runtime
import re import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar from typing import ClassVar
from lfx.custom import Component from lfx.custom import Component
@ -48,94 +46,16 @@ from lfx.io import (
) )
from lfx.schema.data import Data 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: # <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
if value is None: # (LFX executes embedded source without the kirche package) -->
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+([^\]]+)\]")
class CustomInputBauvorhaben(Component): class CustomInputBauvorhaben(Component):
@ -246,9 +166,11 @@ class CustomInputBauvorhaben(Component):
""" """
inputs = getattr(self, "_inputs", None) inputs = getattr(self, "_inputs", None)
info = "" info = ""
structured = None
if isinstance(inputs, dict) and name in inputs: if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or "" 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: if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible), # Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser. # mirroring the backend parser.
@ -262,33 +184,14 @@ class CustomInputBauvorhaben(Component):
return True return True
def collect(self) -> Data: def collect(self) -> Data:
try: payload, asked = parse_envelope(self.form_values)
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
# Effective trigger values: submitted first, code defaults after. # Effective trigger values: submitted first, code defaults after.
declared = self._field_names() declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared} field_values = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items(): effective, scope = collect_form_data(declared, field_values, payload, asked)
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
# Defaults only for asked AND visible fields (hidden branch fields # Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked # stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground). # 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)} data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are # Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code # kept: the frontend may already know renamed fields this code

View file

@ -34,8 +34,8 @@ upload comes later.
from __future__ import annotations from __future__ import annotations
import json import json # noqa: F401 -- used by inlined _form_core at runtime
import re import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar from typing import ClassVar
from lfx.custom import Component from lfx.custom import Component
@ -56,97 +56,16 @@ from lfx.io import (
) )
from lfx.schema.data import Data 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: # <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
if value is None: # (LFX executes embedded source without the kirche package) -->
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+([^\]]+)\]")
class CustomInputForm(Component): class CustomInputForm(Component):
@ -272,9 +191,11 @@ class CustomInputForm(Component):
""" """
inputs = getattr(self, "_inputs", None) inputs = getattr(self, "_inputs", None)
info = "" info = ""
structured = None
if isinstance(inputs, dict) and name in inputs: if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or "" 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: if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible), # Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser. # mirroring the backend parser.
@ -288,33 +209,14 @@ class CustomInputForm(Component):
return True return True
def collect(self) -> Data: def collect(self) -> Data:
try: payload, asked = parse_envelope(self.form_values)
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
# Effective trigger values: submitted first, code defaults after. # Effective trigger values: submitted first, code defaults after.
declared = self._field_names() declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared} field_values = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items(): effective, scope = collect_form_data(declared, field_values, payload, asked)
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
# Defaults only for asked AND visible fields (hidden branch fields # Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked # stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground). # 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)} data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are # Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code # kept: the frontend may already know renamed fields this code

View file

@ -1,11 +1,9 @@
"""Mitarbeiter-einstellen form component for kirche-einfach flows. """Mitarbeiter-einstellen form component for kirche-einfach flows.
Standalone by design: this module shares NO code with the other form Thin variant: shared runtime lives in ``kirche._form_core`` (imported here
modules (no imports from them). The LFX runtime executes the embedded for the Langflow palette); ``scripts/make_flow_fixtures.py:kirche_node()``
module source without our ``kirche`` package, so every form component inlines the core into the exported ``code`` so the LFX runtime stays
must be self-contained (stdlib + ``lfx`` only). Do NOT refactor the self-contained. Copy this file (or ``bauvorhaben_form.py``) as the starting
duplicated machinery below into a shared import — it would break flow
execution. Copy this file (or ``bauvorhaben_form.py``) as the starting
point for future forms. point for future forms.
A flow that starts with :class:`CustomInputMitarbeiter` declares the A flow that starts with :class:`CustomInputMitarbeiter` declares the
@ -33,8 +31,8 @@ Langflow playground), all visible declared defaults apply.
from __future__ import annotations from __future__ import annotations
import json import json # noqa: F401 -- used by inlined _form_core at runtime
import re import re # noqa: F401 -- used by inlined _form_core at runtime
from typing import ClassVar from typing import ClassVar
from lfx.custom import Component from lfx.custom import Component
@ -46,94 +44,16 @@ from lfx.io import (
) )
from lfx.schema.data import Data 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: # <-- _form_core inlined by scripts/make_flow_fixtures.py:kirche_node()
if value is None: # (LFX executes embedded source without the kirche package) -->
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+([^\]]+)\]")
class CustomInputMitarbeiter(Component): class CustomInputMitarbeiter(Component):
@ -223,9 +143,11 @@ class CustomInputMitarbeiter(Component):
""" """
inputs = getattr(self, "_inputs", None) inputs = getattr(self, "_inputs", None)
info = "" info = ""
structured = None
if isinstance(inputs, dict) and name in inputs: if isinstance(inputs, dict) and name in inputs:
info = getattr(inputs[name], "info", "") or "" 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: if trigger == name or trigger not in effective:
# Self-reference or unknown trigger: fail open (visible), # Self-reference or unknown trigger: fail open (visible),
# mirroring the backend parser. # mirroring the backend parser.
@ -239,33 +161,14 @@ class CustomInputMitarbeiter(Component):
return True return True
def collect(self) -> Data: def collect(self) -> Data:
try: payload, asked = parse_envelope(self.form_values)
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
# Effective trigger values: submitted first, code defaults after. # Effective trigger values: submitted first, code defaults after.
declared = self._field_names() declared = self._field_names()
effective = {name: _json_safe(self._field_value(name)) for name in declared} field_values = {name: _json_safe(self._field_value(name)) for name in declared}
for key, val in payload.items(): effective, scope = collect_form_data(declared, field_values, payload, asked)
if isinstance(key, str) and key:
effective[key] = _json_safe(val)
# Defaults only for asked AND visible fields (hidden branch fields # Defaults only for asked AND visible fields (hidden branch fields
# stay out, like the frontend never submits them); without an asked # stay out, like the frontend never submits them); without an asked
# list, all visible declared fields (Langflow playground). # 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)} data = {name: effective[name] for name in scope if self._is_visible(name, effective)}
# Submitted values win over the field defaults. Unknown keys are # Submitted values win over the field defaults. Unknown keys are
# kept: the frontend may already know renamed fields this code # 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" requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.14", "alembic>=1.14",
"atomicwrites>=1.4.1",
"fastapi[standard]>=0.115.0", "fastapi[standard]>=0.115.0",
"fastapi-mail>=1.4.1",
"httpx>=0.28", "httpx>=0.28",
"jinja2>=3.1",
"pydantic-settings>=2.0", "pydantic-settings>=2.0",
"pyjwt>=2.10",
"python-slugify>=8.0",
"secure>=1.0",
"slowapi>=0.1.9",
"sqlmodel>=0.0.24", "sqlmodel>=0.0.24",
"tenacity>=9.0",
"typer>=0.15",
"uvicorn[standard]>=0.30.0", "uvicorn[standard]>=0.30.0",
"watchfiles>=0.24", "watchfiles>=0.24",
] ]

View file

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

View file

@ -7,11 +7,11 @@ Usage:
from __future__ import annotations from __future__ import annotations
import argparse
import json import json
import sys import sys
import httpx import httpx
import typer
def post(api: str, path: str, body: dict) -> dict: 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]} return {"status": 0, "body": str(exc)[:2000]}
def main() -> int: def main(
parser = argparse.ArgumentParser(description=__doc__) langflow_id: str = typer.Option(..., "--langflow-id"),
parser.add_argument("--api", default="http://localhost:8000") api: str = typer.Option("http://localhost:8000", "--api"),
parser.add_argument("--langflow-id", required=True) name: str | None = typer.Option(None, "--name"),
parser.add_argument("--name", default=None) author: str = typer.Option("", "--author"),
parser.add_argument("--author", default="") no_deploy: bool = typer.Option(False, "--no-deploy"),
parser.add_argument("--no-deploy", action="store_true") ) -> int:
args = parser.parse_args() """Import a flow from Langflow via the running API (post() stays importable)."""
result = post( result = post(
args.api, api,
"/api/flows/import", "/api/flows/import",
{ {
"langflow_flow_id": args.langflow_id, "langflow_flow_id": langflow_id,
"name": args.name, "name": name,
"author": args.author, "author": author,
"deploy": not args.no_deploy, "deploy": not no_deploy,
}, },
) )
print(json.dumps(result, indent=2, ensure_ascii=False)) print(json.dumps(result, indent=2, ensure_ascii=False))
@ -50,4 +49,4 @@ def main() -> int:
if __name__ == "__main__": 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 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 # node builders
@ -118,7 +186,7 @@ def kirche_node(
comp_name = getattr(component_cls, "name", None) or component_cls.__name__ comp_name = getattr(component_cls, "name", None) or component_cls.__name__
inst = component_cls() inst = component_cls()
if code_override is not None: 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)) frontend_node = ComponentFrontendNode.from_inputs(**inst.get_template_config(inst))
inst._map_parameters_on_frontend_node(frontend_node) inst._map_parameters_on_frontend_node(frontend_node)
node = frontend_node.to_dict(keep_name=False) node = frontend_node.to_dict(keep_name=False)
@ -126,6 +194,10 @@ def kirche_node(
inst._map_parameters_on_template(node["template"]) inst._map_parameters_on_template(node["template"])
if not getattr(inst, "_code", None): if not getattr(inst, "_code", None):
inst.set_class_code() 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"] = { node["template"]["code"] = {
"advanced": True, "advanced": True,
"api_editable": False, "api_editable": False,

View file

@ -37,6 +37,9 @@ LFX_PORT = _free_port()
API_PORT = _free_port() API_PORT = _free_port()
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs" 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: def wait_healthy(url: str, timeout: int = 180) -> None:
deadline = time.time() + timeout deadline = time.time() + timeout
@ -54,9 +57,9 @@ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | s
import httpx import httpx
with httpx.Client(base_url=f"http://127.0.0.1:{API_PORT}", timeout=120) as c: 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 # Management + runs are login-gated; the smoke admin JWT is minted
# planted directly in the throwaway DB (see main()). # in main() (stateless sessions, see app/auth/service.py).
c.cookies.set("kirche_session", "smoke-session", domain="127.0.0.1") c.cookies.set("kirche_session", _SMOKE_JWT, domain="127.0.0.1")
r = c.request(method, path, json=body) r = c.request(method, path, json=body)
try: try:
return r.status_code, r.json() return r.status_code, r.json()
@ -90,27 +93,31 @@ def main() -> None:
app_settings.database_url = db_url app_settings.database_url = db_url
alembic_command.upgrade(cfg, "head") alembic_command.upgrade(cfg, "head")
# Plant an admin session: management + runs require login, and there is # Plant an admin user + mint its session JWT: management + runs
# no SMTP in smoke to deliver a magic link. # require login, and there is no SMTP in smoke to deliver a magic link.
from datetime import UTC, datetime, timedelta # 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 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) engine = create_engine(db_url)
with Session(engine) as s: with Session(engine) as s:
user = User(name="smoke", email="smoke@example.com", role="admin") user = User(name="smoke", email="smoke@example.com", role="admin")
s.add(user) 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.commit()
s.refresh(user)
_SMOKE_JWT = auth_service.mint_session_token(user)
print("smoke admin JWT minted")
engine.dispose() engine.dispose()
env = { env = {

View file

@ -9,12 +9,12 @@ Exit codes: 0 ok, 1 sync errors, 2 flows dir missing.
from __future__ import annotations from __future__ import annotations
import argparse
import asyncio import asyncio
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
import typer
from sqlmodel import Session from sqlmodel import Session
from app.db import get_engine, reset_engine from app.db import get_engine, reset_engine
@ -80,18 +80,17 @@ async def main_async(args) -> int:
return 0 return 0
def main() -> int: def main(
parser = argparse.ArgumentParser(description=__doc__) pull: bool = typer.Option(False, "--pull", help="export all Langflow flows first"),
parser.add_argument( sync: bool = typer.Option(False, "--sync", help="reconcile backend/flows into DB + LFX"),
"--pull", action="store_true", help="export all Langflow flows to backend/flows first" dir: str | None = typer.Option(None, "--dir", help="flows directory"),
) ) -> int:
parser.add_argument("--sync", action="store_true", help="reconcile backend/flows into DB + LFX") """Reconcile flows (typer CLI; run_sync/run_full_pass stay importable)."""
parser.add_argument("--dir", default=None, help="flows directory (default: settings.flows_dir)") if not pull and not sync:
args = parser.parse_args() sync = True
if not args.pull and not args.sync: args = type("Args", (), {"pull": pull, "sync": sync, "dir": dir})()
args.sync = True
return asyncio.run(main_async(args)) return asyncio.run(main_async(args))
if __name__ == "__main__": 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 = Config()
cfg.set_main_option("script_location", str(backend_dir / "migrations")) cfg.set_main_option("script_location", str(backend_dir / "migrations"))
alembic_command.upgrade(cfg, "head") 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") os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
# Flow callbacks (POST /api/approvals) authenticate with this token. # Flow callbacks (POST /api/approvals) authenticate with this token.
os.environ["CALLBACK_TOKEN"] = "test-callback-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 # The embedded watcher + startup sync stay off in tests; sync is exercised
# directly (sync_from_directory) with per-test tmp dirs instead. # directly (sync_from_directory) with per-test tmp dirs instead.
os.environ["FLOWS_WATCH_ENABLED"] = "false" os.environ["FLOWS_WATCH_ENABLED"] = "false"
@ -93,13 +95,11 @@ def login(client, session):
role: str = "admin", role: str = "admin",
tenant_id: str = "default", tenant_id: str = "default",
): ):
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from sqlmodel import select from sqlmodel import select
from app.auth import service
from app.auth.deps import SESSION_COOKIE 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 # Idempotent: re-login updates the existing row (tests switch
# identity mid-test by calling login() again). # identity mid-test by calling login() again).
@ -111,15 +111,9 @@ def login(client, session):
user.role = role user.role = role
user.tenant_id = tenant_id user.tenant_id = tenant_id
session.add(user) 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() 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 user
return _login return _login
@ -132,76 +126,33 @@ def lfx_server(tmp_path_factory):
Serves an (initially seeded) flow dir; tests upload fixture flows through Serves an (initially seeded) flow dir; tests upload fixture flows through
the same deploy path production uses. the same deploy path production uses.
""" """
import shutil
import socket
import subprocess import subprocess
import sys
import time
import urllib.request
from pathlib import Path from pathlib import Path
from app.settings import settings from app.settings import settings
bindir = Path(sys.executable).parent from ._harness import TEST_LFX_KEY, free_port, spawn_lfx, wait_healthy
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()
port = free_port()
flow_dir = tmp_path_factory.mktemp("lfx-flows") flow_dir = tmp_path_factory.mktemp("lfx-flows")
seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text() seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text()
(flow_dir / "seed.json").write_text(seed) (flow_dir / "seed.json").write_text(seed)
env = { proc = spawn_lfx(flow_dir, port)
**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,
)
url = f"http://127.0.0.1:{port}" url = f"http://127.0.0.1:{port}"
deadline = time.time() + 240 try:
healthy = False wait_healthy(url)
while time.time() < deadline: except Exception:
if proc.poll() is not None: if proc.poll() is not None:
out = proc.stdout.read() if proc.stdout else "" out = proc.stdout.read() if proc.stdout else ""
raise RuntimeError(f"lfx serve exited early:\n{out[-4000:]}") 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() proc.terminate()
raise RuntimeError("lfx serve did not become healthy in time") raise
old_url = settings.lfx_url old_url = settings.lfx_url
old_key = settings.lfx_api_key old_key = settings.lfx_api_key
settings.lfx_url = url settings.lfx_url = url
settings.lfx_api_key = "test-lfx-key" settings.lfx_api_key = TEST_LFX_KEY
yield url yield url
settings.lfx_url = old_url settings.lfx_url = old_url
settings.lfx_api_key = old_key settings.lfx_api_key = old_key
@ -259,21 +210,11 @@ def override_langflow():
def live_server(): def live_server():
"""Real uvicorn server (ephemeral port) so LFX flows can call back """Real uvicorn server (ephemeral port) so LFX flows can call back
into our API over HTTP, exactly like production.""" into our API over HTTP, exactly like production."""
import threading
import time
import uvicorn
from app.main import app from app.main import app
config = uvicorn.Config(app, host="127.0.0.1", port=0, log_level="warning") from ._harness import spawn_uvicorn
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True) server, thread = spawn_uvicorn(app)
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"
port = server.servers[0].sockets[0].getsockname()[1] port = server.servers[0].sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}" yield f"http://127.0.0.1:{port}"
server.should_exit = True 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: 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 { return {
"id": flow_id, "id": flow_id,
"name": name, "name": name,
"description": "", "description": "aus Langflow",
"data": {"nodes": [], "edges": []}, "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.""" """Step 5 tests: decisions + continuations, tenant isolation, error paths."""
import json
from pathlib import Path
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from app.flows.schemas import ApprovalCreate from app.flows.schemas import ApprovalCreate
FIXTURES = Path(__file__).parent / "fixtures" / "flows" from .conftest import load_flow_fixture
def load_fixture(name: str) -> dict: 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): 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 me.json()["tenant_id"] == "default"
assert (await member.post("/api/auth/logout")).status_code == 200 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 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"] 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") await _login(client, session, MEMBER, role="member")
created = await client.post( created = await client.post(
"/api/access/requests", "/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") approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approve.status_code == 403 assert approve.status_code == 403
from httpx import ASGITransport, AsyncClient async for anon in anon_client():
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
assert (await anon.get("/api/access/requests")).status_code == 401 assert (await anon.get("/api/access/requests")).status_code == 401
assert ( assert (
await anon.post(f"/api/access/requests/{created.json()['id']}/approve") 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) token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token}) response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200 assert response.status_code == 200
raw = " ".join(response.headers.get_list("set-cookie")).lower() cookies = response.headers.get_list("set-cookie")
assert "httponly" in raw assert any("httponly" in c.lower() for c in cookies), cookies
assert "secure" in raw assert any("secure" in c.lower() for c in cookies), cookies
assert "samesite=lax" in raw assert any("samesite=lax" in c.lower() for c in cookies), cookies
assert "path=/" in raw assert any("path=/" in c.lower() for c in cookies), cookies
async def test_no_token_or_session_in_logs(client, session, caplog): async def test_no_token_or_session_in_logs(client, session, caplog):
@ -225,15 +223,12 @@ def test_resolve_tenant():
def _broken_smtp(monkeypatch): def _broken_smtp(monkeypatch):
import smtplib
from app.auth import mail from app.auth import mail
class ExplodingSMTP: def _explode(message):
def __init__(self, *args, **kwargs): raise OSError("relay down")
raise smtplib.SMTPException("relay down")
monkeypatch.setattr(mail.smtplib, "SMTP", ExplodingSMTP) monkeypatch.setattr(mail, "_send", _explode)
def test_magic_link_delivery_failure_returns_false(monkeypatch): 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_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
html = html_part.get_content() html = html_part.get_content()
# Button = anchor with href around the label; token never in text-only form. # 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 "http://localhost:3000/login?token=GEHEIM" in html
assert ">Jetzt anmelden</a>" in html assert "Jetzt anmelden" in html
assert mail.BLUE in html # kirchenblau brand bar
assert 'lang="de"' in html assert 'lang="de"' in html
# Multipart: plain-text fallback keeps the raw link. # Multipart: plain-text fallback keeps the raw link.
parts = [p.get_content_type() for p in message.walk()] parts = [p.get_content_type() for p in message.walk()]

View file

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

View file

@ -1,27 +1,20 @@
"""Step 4 tests: Langflow import pipeline (Langflow faked, LFX real).""" """Step 4 tests: Langflow import pipeline (Langflow faked, LFX real)."""
import json import json
from pathlib import Path
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest
from app.flows.sync import lfx_id_for 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" LANGFLOW_ID = "11111111-2222-4333-8444-555555555555"
def _export_doc(name="importiert"): def _export_doc(name="importiert"):
fixture = json.loads((FIXTURES / "simple_rule.json").read_text()) """Alias of the shared conftest helper (kept for call-site stability)."""
return { return langflow_export_doc(name, LANGFLOW_ID)
"id": LANGFLOW_ID,
"name": name,
"description": "aus Langflow",
"endpoint_name": None,
"data": fixture["data"],
"tags": [],
"webhook": False,
"is_component": False,
}
async def test_import_creates_definition_version_and_deploys( 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" 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): async def test_import_without_deploy(client, login, override_langflow):
override_langflow(_export_doc()) override_langflow(_export_doc())
await login() await login()
@ -113,18 +114,19 @@ async def test_import_without_deploy(client, login, override_langflow):
assert body["version"]["deployed_at"] is None assert body["version"]["deployed_at"] is None
async def test_import_langflow_not_found(client, login, override_langflow): @pytest.mark.parametrize(
override_langflow(status=404) ("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() await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}) target = LANGFLOW_ID if fake_status == 404 else str(uuid4())
assert response.status_code == 404 response = await client.post("/api/flows/import", json={"langflow_flow_id": target})
assert response.status_code == expected_status
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
async def test_list_langflow_flows(client, login, override_langflow): 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.""" """Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E."""
import json import json
from pathlib import Path
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest
from app.flows.sync import lfx_id_for 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"} CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict: 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"): async def _create_flow(client, name="regeltest"):
@ -55,59 +58,57 @@ async def test_flow_crud(client, login):
assert response.status_code == 404 assert response.status_code == 404
async def test_management_endpoints_require_admin(client, login): MANAGEMENT_PROBES = [
"""Anonymous -> 401, member -> 403, admin -> through (A).""" ("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() flow_id = uuid4()
probes = [ target = path.format(flow_id=flow_id)
("POST", "/api/flows", {"name": "x"}), if method == "POST":
("POST", "/api/flows/sync", None), response = await client.post(target, json=body or {})
("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}), else:
("GET", f"/api/flows/{flow_id}/versions", None), response = await client.get(target)
("POST", f"/api/flows/{flow_id}/versions/1/deploy", None), assert response.status_code == 401, (method, target, response.text)
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
("GET", "/api/flows/langflow/available", None),
]
for method, path, body in probes:
if method == "POST":
response = await client.post(path, json=body or {})
else:
response = await client.get(path)
assert response.status_code == 401, (method, path, response.text)
await login(email="member@example.com", role="member") await login(email="member@example.com", role="member")
for method, path, body in probes: if method == "POST":
if method == "POST": response = await client.post(target, json=body or {})
response = await client.post(path, json=body or {}) else:
else: response = await client.get(target)
response = await client.get(path) assert response.status_code == 403, (method, target, response.text)
assert response.status_code == 403, (method, path, response.text)
async def test_runs_and_reads_require_login(client, login): LOGIN_GATED_PATHS = [
"""Run, run reads and approval reads are login-gated (A).""" ("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() await login()
flow = await _create_flow(client, "gated") flow = await _create_flow(client, "gated")
run_id = uuid4() target = path.format(flow_id=flow["id"], run_id=uuid4(), task_id=uuid4())
authed_paths = [ async for anon in anon_client():
("POST", f"/api/flows/{flow['id']}/run", {"input": {}}), if method == "POST":
("GET", f"/api/flows/{flow['id']}/runs", None), response = await anon.post(target, json=body or {})
("GET", f"/api/flows/runs/{run_id}", None), else:
("GET", "/api/approvals", None), response = await anon.get(target)
("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}), assert response.status_code == 401, (method, target, response.text)
]
# Fresh client without a session cookie.
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as anon:
for method, path, body in authed_paths:
if method == "POST":
response = await anon.post(path, json=body or {})
else:
response = await anon.get(path)
assert response.status_code == 401, (method, path, response.text)
async def test_version_validation_and_numbering(client, login): 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" current_event = "message"
assert events, "expected SSE events" assert events, "expected SSE events"
assert events[0][0] == "run" by_type = {}
first_payload = json.loads(events[0][1]) for event, data in events:
assert "run_id" in first_payload by_type.setdefault(event, []).append(json.loads(data))
assert events[-1][0] == "run_finished" assert "run_id" in by_type["run"][0]
last_payload = json.loads(events[-1][1]) finished = [p for p in by_type.get("run_finished", []) if "status" in p]
assert last_payload["status"] == "completed" 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']}") response = await client.get(f"/api/flows/runs/{first_payload['run_id']}")
assert response.status_code == 200 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) field["name"] for field in template.values() if isinstance(field, dict)
} - {"code"} } - {"code"}
code = template["code"]["value"] 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) cls = validate_mod.create_class(code, class_name)
code_names = {inp.name for inp in cls.inputs} code_names = {inp.name for inp in cls.inputs}
assert code_names == template_names, filename 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(): def test_bauvorhaben_fixture_shape():
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8")) doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
assert doc["name"] == "bauvorhaben" assert doc["name"] == "bauvorhaben"
@ -332,8 +375,8 @@ def test_bauvorhaben_schema_branches():
schema = out["json_schema"] schema = out["json_schema"]
assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"} assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"}
assert schema["required"] == ["gebaeude"] assert schema["required"] == ["gebaeude"]
assert len(schema["allOf"]) == 5
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]} 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) == { assert set(by_field) == {
"bestandteil", "bestandteil",
"glockenmassnahme", "glockenmassnahme",
@ -355,7 +398,9 @@ def test_bauvorhaben_schema_branches():
assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][ assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][
"bestandteil" "bestandteil"
].get("description", "") ].get("description", "")
assert out["ui_schema"]["ui:order"] == [ order = out["ui_schema"]["ui:order"]
assert order[-1] == "*"
assert set(order[:-1]) == {
"gebaeude", "gebaeude",
"bestandteil", "bestandteil",
"glockenmassnahme", "glockenmassnahme",
@ -363,8 +408,7 @@ def test_bauvorhaben_schema_branches():
"gebaeudeart", "gebaeudeart",
"denkmalschutz", "denkmalschutz",
"denkmalnummer", "denkmalnummer",
"*", }
]
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login): 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 pathlib import Path
from uuid import uuid4 from uuid import uuid4
import pytest
from app.flows import input_schema as parser from app.flows import input_schema as parser
from app.flows import sync from app.flows import sync
from app.flows.lfx_client import LfxClient from app.flows.lfx_client import LfxClient
from app.settings import settings from app.settings import settings
FIXTURES = Path(__file__).parent / "fixtures" / "flows" from .conftest import FIXTURES_DIR as FIXTURES
def _field(name, itype, **kw): def _field(name, itype, **kw):
@ -48,14 +50,55 @@ def _branch_schema(doc):
return schema 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( doc = _doc(
_field("a", "StrInput", value="x"), _field("a", "StrInput", value="x"),
_field("b", "MessageTextInput"),
_field("c", "MultilineInput"),
_field("d", "SecretStrInput"),
_field("e", "IntInput", value=3), _field("e", "IntInput", value=3),
_field("f", "FloatInput", value=1.5),
_field("g", "BoolInput", value=True), _field("g", "BoolInput", value=True),
_field( _field(
"h", "h",
@ -75,16 +118,11 @@ def test_type_mapping_table():
value=3, value=3,
range_spec={"min": 1, "max": 5, "step": 1}, range_spec={"min": 1, "max": 5, "step": 1},
), ),
_field("k", "FileInput"),
_field("l", "FutureFancyInput"),
) )
schema = parser.extract_form_schema(doc) schema = parser.extract_form_schema(doc)
assert "fallback" not in schema
props = schema["json_schema"]["properties"] props = schema["json_schema"]["properties"]
assert props["a"] == {"title": "A", "type": "string", "default": "x"} 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["e"] == {"title": "E", "type": "integer", "default": 3}
assert props["f"]["type"] == "number"
assert props["g"] == {"title": "G", "type": "boolean", "default": True} assert props["g"] == {"title": "G", "type": "boolean", "default": True}
assert props["h"] == { assert props["h"] == {
"title": "H", "title": "H",
@ -98,12 +136,6 @@ def test_type_mapping_table():
assert props["j"]["minimum"] == 1 assert props["j"]["minimum"] == 1
assert props["j"]["maximum"] == 5 assert props["j"]["maximum"] == 5
assert props["j"]["multipleOf"] == 1 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(): def test_dropdown_default_outside_enum_omitted():
@ -122,7 +154,9 @@ def test_required_advanced_hidden_code():
schema = parser.extract_form_schema(doc) schema = parser.extract_form_schema(doc)
assert schema["json_schema"]["required"] == ["a"] assert schema["json_schema"]["required"] == ["a"]
assert set(schema["json_schema"]["properties"]) == {"a", "b"} 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(): def test_display_name_match_and_fallback():
@ -260,7 +294,9 @@ def test_visible_if_single_branch():
assert rule["then"]["required"] == ["bestandteil"] assert rule["then"]["required"] == ["bestandteil"]
# Marker stripped from the shown description. # Marker stripped from the shown description.
assert schema["properties"]["gebaeude"]["title"] == "Gebaeude" 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(): 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): async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
from app.settings import settings 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() await login()
flow = (await client.post("/api/flows", json={"name": "throttled"})).json() 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). # 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": {}}) response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
assert response.status_code == 422 assert response.status_code == 422
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}}) 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): def test_flow_definition_defaults(session):
from datetime import UTC, datetime
before = datetime.now(UTC).replace(tzinfo=None)
d = FlowDefinition(name="bauvorhaben") d = FlowDefinition(name="bauvorhaben")
session.add(d) session.add(d)
session.commit() session.commit()
@ -18,7 +21,8 @@ def test_flow_definition_defaults(session):
assert d.created_at is not None assert d.created_at is not None
# Timestamps are written as UTC. SQLite does not round-trip tzinfo, so # Timestamps are written as UTC. SQLite does not round-trip tzinfo, so
# after a refresh the value is calendar-time UTC without tzinfo. # 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): 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.flows.sync import lfx_id_for
from app.settings import settings from app.settings import settings
FIXTURES = Path(__file__).parent / "fixtures" / "flows" from .conftest import FIXTURES_DIR, langflow_export_doc, load_flow_fixture
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)
def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path: 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 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): async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
_seed_dir(tmp_path) _seed_dir(tmp_path)
report = await sync.sync_from_directory(session, _lfx(), 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: def _langflow_doc(name: str, langflow_id: str) -> dict:
doc = load_fixture("simple_rule.json") """Alias of the shared conftest helper (kept for call-site stability)."""
return { return langflow_export_doc(name, langflow_id)
"id": langflow_id,
"name": name,
"description": "aus Langflow",
"endpoint_name": None,
"data": doc["data"],
"tags": [],
"webhook": False,
"is_component": False,
}
async def test_sync_endpoint_pulls_from_langflow_then_syncs( 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"] assert "kaputt-id" in body["errors"]
async def test_sync_endpoint_langflow_unreachable( @pytest.mark.parametrize(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow ("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() await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path)) 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") response = await client.post("/api/flows/sync")
assert response.status_code == 503 assert response.status_code == expected_status
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
async def test_watch_forever_triggers_on_file_change(tmp_path): async def test_watch_forever_triggers_on_file_change(tmp_path):

View file

@ -9,20 +9,28 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@mantine/core": "^9.6.1", "@mantine/core": "^9.6.1",
"@mantine/dates": "^9.6.1",
"@mantine/form": "^9.6.1",
"@mantine/hooks": "^9.6.1", "@mantine/hooks": "^9.6.1",
"@mantine/notifications": "^9.6.1",
"@rjsf/core": "^6.10.0", "@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0", "@rjsf/mantine": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0", "@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0", "@tabler/icons-react": "^3.46.0",
"@tanstack/react-query": "^5.62.0",
"dayjs": "^1.11.13",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0", "playwright": "^1.63.0",
"sharp": "^0.34.0",
"typescript": "~5.6.0", "typescript": "~5.6.0",
"vite": "^6.0.0" "vite": "^6.0.0"
} }
@ -261,6 +269,15 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@ -309,6 +326,17 @@
"node": ">=6.9.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -804,6 +832,496 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -873,22 +1391,35 @@
} }
}, },
"node_modules/@mantine/dates": { "node_modules/@mantine/dates": {
"version": "9.6.1", "version": "9.6.3",
"resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-9.6.3.tgz",
"integrity": "sha512-3NxfqrcwMuYB9wvxIhGhEhhazK3wcA2wG4AuNVFGJDd97ujWWAy+jGHqBF9jOusp/Mk4bn8u+3SuDICtLPDf5w==", "integrity": "sha512-sPDlPBKl0R8VR9mmTVaNvuvsczD79qMbZOpe+wjkQkrvE102G+iwk2cxW8UIA+o8jqc6jAP4Cf2aoy9EqJ/hiQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"clsx": "^2.1.1" "clsx": "^2.1.1"
}, },
"peerDependencies": { "peerDependencies": {
"@mantine/core": "9.6.1", "@mantine/core": "9.6.3",
"@mantine/hooks": "9.6.1", "@mantine/hooks": "9.6.3",
"dayjs": ">=1.0.0", "dayjs": ">=1.0.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^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": { "node_modules/@mantine/hooks": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.6.1.tgz",
@ -898,6 +1429,31 @@
"react": "^19.2.0" "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": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "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": { "node_modules/@rjsf/utils": {
"version": "6.10.0", "version": "6.10.1",
"resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.0.tgz", "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.10.1.tgz",
"integrity": "sha512-GQa+dr28FgGOaRRhIOX3QEv1GkzoklrZYf4f1bTEDNuAWWWyO1oznw62ryZBj4FI/XKPaE/VyJQlssOSbD5KiQ==", "integrity": "sha512-dlbQUCoTl/+/TenyYs1zAKXf9sLTebtY+6pB+bUFsaN4xkdAtR8YBLwmpa1N5yNiRzf3fbxveISNuZmDrZE/yQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@x0k/json-schema-merge": "^1.0.3", "@x0k/json-schema-merge": "^1.0.6",
"fast-equals": "^6.0.0", "fast-equals": "^6.0.0",
"fast-uri": "^4.1.4", "fast-uri": "^4.1.4",
"jsonpointer": "^5.0.1", "jsonpointer": "^5.0.1",
@ -1344,6 +1899,12 @@
"win32" "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": { "node_modules/@tabler/icons": {
"version": "3.46.0", "version": "3.46.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz", "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
@ -1370,6 +1931,32 @@
"react": ">= 16" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1426,14 +2013,13 @@
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1471,11 +2057,10 @@
} }
}, },
"node_modules/@x0k/json-schema-merge": { "node_modules/@x0k/json-schema-merge": {
"version": "1.0.5", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@x0k/json-schema-merge/-/json-schema-merge-1.0.6.tgz",
"integrity": "sha512-bPvwYpTkhBOXkvbC8KMNhkgzv+gWu6Yx6FVho3mL+vMf8Fy3gdn9YrlrBrXphznvOHhB85BudH/ywtVRZ/HG3A==", "integrity": "sha512-SSdWAhirRj9UGTSjmgMnchC3Nytpbde46qPEXVQLm/S3opteW6E/VX4UzLlfmA9L2zdkmG3c69sfLfHbT0jl0w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/json-schema": "^7.0.15" "@types/json-schema": "^7.0.15"
} }
@ -1630,7 +2215,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dayjs": { "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": { "node_modules/detect-node-es": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.420", "version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
@ -1729,19 +2333,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": { "node_modules/fast-equals": {
"version": "6.0.3", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.3.tgz", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.4.tgz",
"integrity": "sha512-IECu4C0fFZFoUC2cA2rDaNUuVIfWIZMwh3tY+ng924vOuD9OaUFFrcIrVwCKbV7TJDkGmrHfhwZ7R0AnHfVhyw==", "integrity": "sha512-JiBeR0k76FIahi9zAtQR7X1jgZDKLE+fs5S9ryjOGYZWZCOcm8QiwgeahkFvaboS3i6PkGBopRznHKuKhm9zpg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "4.1.4", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.1.tgz",
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==", "integrity": "sha512-TmHQgewjHtMq1E5QKA0tOE0yeYGQs25KZC/ziJpubRtWI15W92e6vPFydWOeZBVROSHBYw/QhD9d5OefdD6LDg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -1752,8 +2355,7 @@
"url": "https://opencollective.com/fastify" "url": "https://opencollective.com/fastify"
} }
], ],
"license": "BSD-3-Clause", "license": "BSD-3-Clause"
"peer": true
}, },
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
@ -1811,7 +2413,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsesc": { "node_modules/jsesc": {
@ -1851,11 +2452,31 @@
"resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -1930,6 +2551,15 @@
"node": ">=18" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2008,6 +2638,23 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@ -2033,8 +2680,7 @@
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/react-number-format": { "node_modules/react-number-format": {
"version": "5.4.5", "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": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "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==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2475,6 +3195,15 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "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": { "dependencies": {
"@mantine/core": "^9.6.1", "@mantine/core": "^9.6.1",
"@mantine/dates": "^9.6.1",
"@mantine/form": "^9.6.1",
"@mantine/hooks": "^9.6.1", "@mantine/hooks": "^9.6.1",
"@mantine/notifications": "^9.6.1",
"@rjsf/core": "^6.10.0", "@rjsf/core": "^6.10.0",
"@rjsf/mantine": "^6.10.0", "@rjsf/mantine": "^6.10.0",
"@rjsf/utils": "^6.10.0",
"@rjsf/validator-ajv8": "^6.10.0", "@rjsf/validator-ajv8": "^6.10.0",
"@tabler/icons-react": "^3.46.0", "@tabler/icons-react": "^3.46.0",
"@tanstack/react-query": "^5.62.0",
"dayjs": "^1.11.13",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^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": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.63.0", "playwright": "^1.63.0",
"sharp": "^0.34.0",
"typescript": "~5.6.0", "typescript": "~5.6.0",
"vite": "^6.0.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/ | | 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/ | | 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/ | | 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/ | | 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(); const page = await context.newPage();
for (const shot of shots) { for (const shot of shots) {
await page.goto(base + shot.path, { waitUntil: "networkidle" }); await page.goto(base + shot.path, { waitUntil: "networkidle" });
await page.waitForTimeout(1500); await page.waitForSelector("main, #root", { timeout: 15000 });
if (shot.selectFlow) { if (shot.selectFlow) {
// Show the generated RJSF form instead of the default first flow. // Show the generated RJSF form instead of the default first flow.
await page.locator('[role="combobox"]').first().click(); await page.locator('[role="combobox"]').first().click();
await page.getByRole("option", { name: shot.selectFlow }).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`); const file = join(outDir, `${shot.name}-${scheme}.png`);
await page.screenshot({ path: file, fullPage: shot.fullPage }); await page.screenshot({ path: file, fullPage: shot.fullPage });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,40 +7,29 @@ import { useAuth } from "../auth";
export default function AdminPage() { export default function AdminPage() {
const { me } = useAuth(); const { me } = useAuth();
if (!me) { const denied = !me || me.role !== "admin";
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>
);
}
return ( return (
<div> <div>
<Title order={2} mb="md"> <Title order={2} mb="md">
Verwaltung Verwaltung
</Title> </Title>
<Stack gap="xl"> {denied ? (
<AccessQueue /> <Alert
<ApprovalsTab /> color={!me ? "blue" : "red"}
<AdminTab /> icon={me ? <IconAlertCircle size={18} /> : undefined}
</Stack> >
{!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> </div>
); );
} }

View file

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

View file

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

View file

@ -1,43 +1,31 @@
import { useCallback, useEffect, useState } from "react"; import { Table, Text, Title } from "@mantine/core";
import { Alert, Code, Table, Text, Title } from "@mantine/core"; import dayjs from "dayjs";
import { IconAlertCircle } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query";
import { ApiError, api, type Run } from "../api"; import { api } from "../api";
import { formatApiError } from "../utils";
import ApprovalsTab from "../components/ApprovalsTab"; import ApprovalsTab from "../components/ApprovalsTab";
import { ErrorAlert } from "../components/ErrorAlert";
import { JsonView } from "../components/JsonView";
import StatusBadge from "../components/StatusBadge"; import StatusBadge from "../components/StatusBadge";
import { useEffect, useState } from "react";
export default function TasksPage() { export default function TasksPage() {
const [runs, setRuns] = useState<{ flow: string; run: Run }[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const runsQuery = useQuery({
const load = useCallback(async () => { queryKey: ["all-runs"],
try { queryFn: api.listAllRuns,
const flows = await api.listFlows(); });
const all: { flow: string; run: Run }[] = []; const runs = runsQuery.data ?? [];
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));
}
}, []);
useEffect(() => { useEffect(() => {
void load(); if (runsQuery.error) setError(formatApiError(runsQuery.error));
}, [load]); }, [runsQuery.error]);
return ( return (
<div> <div>
<Title order={2} mb="md"> <Title order={2} mb="md">
Aufgaben Aufgaben
</Title> </Title>
{error && ( <ErrorAlert message={error} />
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
{error}
</Alert>
)}
<Title order={3} mb="xs"> <Title order={3} mb="xs">
Eigene Läufe Eigene Läufe
</Title> </Title>
@ -52,17 +40,23 @@ export default function TasksPage() {
<Table.Th>Status</Table.Th> <Table.Th>Status</Table.Th>
<Table.Th>Flow</Table.Th> <Table.Th>Flow</Table.Th>
<Table.Th>Ergebnis</Table.Th> <Table.Th>Ergebnis</Table.Th>
<Table.Th>Erstellt</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{runs.map(({ flow, run }) => ( {runs.map(({ flow_name, run }) => (
<Table.Tr key={run.id}> <Table.Tr key={run.id}>
<Table.Td> <Table.Td>
<StatusBadge status={run.status} /> <StatusBadge status={run.status} />
</Table.Td> </Table.Td>
<Table.Td>{flow}</Table.Td> <Table.Td>{flow_name}</Table.Td>
<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.Td>
</Table.Tr> </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 Form from "@rjsf/mantine";
import validator from "@rjsf/validator-ajv8"; import validator from "@rjsf/validator-ajv8";
import type { RJSFSchema, UiSchema } from "@rjsf/utils"; import type { RJSFSchema, UiSchema } from "@rjsf/utils";
@ -11,57 +11,67 @@ import {
Textarea, Textarea,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconPlayerPlay } from "@tabler/icons-react"; 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 { RunsTable } from "../components/RunsTable";
import { ErrorAlert } from "../components/ErrorAlert"; import { ErrorAlert } from "../components/ErrorAlert";
import { formatApiError } from "../utils"; import { formatApiError } from "../utils";
export default function WorkflowsPage() { export default function WorkflowsPage() {
const [flows, setFlows] = useState<FlowDefinition[]>([]); const queryClient = useQueryClient();
const [flowId, setFlowId] = useState(""); const [flowId, setFlowId] = useState("");
const [schema, setSchema] = useState<InputSchema | null>(null);
const [formData, setFormData] = useState<Record<string, unknown>>({}); const [formData, setFormData] = useState<Record<string, unknown>>({});
const [freeText, setFreeText] = useState(""); const [freeText, setFreeText] = useState("");
const [runs, setRuns] = useState<Run[]>([]);
const [result, setResult] = useState(""); const [result, setResult] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const fail = useCallback((e: unknown) => { const flowsQuery = useQuery({
setError(formatApiError(e)); queryKey: ["flows"],
}, []); queryFn: api.listFlows,
});
const flows = flowsQuery.data ?? [];
useEffect(() => { useEffect(() => {
api const first = flows[0];
.listFlows() if (first) setFlowId((prev) => prev || first.id);
.then((list) => { }, [flows]);
setFlows(list);
const first = list[0];
if (first) setFlowId((prev) => prev || first.id);
})
.catch(fail);
}, [fail]);
useEffect(() => { useEffect(() => {
if (!flowId) return; if (flowsQuery.error) setError(formatApiError(flowsQuery.error));
setError(""); }, [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(""); setResult("");
Promise.all([api.getInputSchema(flowId), api.listRuns(flowId)]) const e = schemaQuery.error;
.then(([s, r]) => { if (!e) {
setSchema(s); setError("");
setFormData({}); return;
setRuns(r); }
}) // Flows without a deployed version have no schema yet.
.catch((e: unknown) => { if (e instanceof ApiError && e.status === 404) {
// Flows without a deployed version have no schema yet. setError("Für diesen Flow ist noch keine Version deployed.");
if (e instanceof ApiError && e.status === 404) { } else {
setSchema(null); setError(formatApiError(e));
setRuns([]); }
setError("Für diesen Flow ist noch keine Version deployed."); }, [flowId, schemaQuery.error]);
} else fail(e); useEffect(() => {
}); if (runsQuery.error) setError(formatApiError(runsQuery.error));
}, [flowId, fail]); }, [runsQuery.error]);
async function onRun(input: unknown) { async function onRun(input: unknown) {
if (!flowId) return; if (!flowId) return;
@ -69,11 +79,21 @@ export default function WorkflowsPage() {
setError(""); setError("");
setResult(""); setResult("");
try { try {
const run = await api.runFlow(flowId, input); const run: Run = await api.runFlow(flowId, input);
setRuns((prev) => [run, ...prev]); setResult(
setResult(run.output?.result ?? run.output?.error ?? run.status); 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) { } catch (e) {
fail(e); setError(formatApiError(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }