Merge branch 'fix/api-authz'
This commit is contained in:
commit
d903d0951f
21 changed files with 662 additions and 160 deletions
|
|
@ -24,6 +24,9 @@ LFX_API_KEY=lfx-
|
||||||
|
|
||||||
# --- API self-reference for flow callbacks (must be reachable FROM lfx) ---
|
# --- API self-reference for flow callbacks (must be reachable FROM lfx) ---
|
||||||
CALLBACK_BASE_URL=http://api:8000
|
CALLBACK_BASE_URL=http://api:8000
|
||||||
|
# Shared secret for flow callbacks (POST /api/approvals carries it as
|
||||||
|
# X-Callback-Token). Long random value; empty disables callbacks.
|
||||||
|
CALLBACK_TOKEN=
|
||||||
|
|
||||||
# --- frontend mode: dev server with HMR (prod .env must set `preview`) ---
|
# --- frontend mode: dev server with HMR (prod .env must set `preview`) ---
|
||||||
FRONTEND_MODE=dev
|
FRONTEND_MODE=dev
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
### Security
|
||||||
|
- API authorization: flow management is admin-only, runs and run/approval
|
||||||
|
reads require login with strict session-tenant scoping, approval
|
||||||
|
decisions record the session user, and flow callbacks authenticate with
|
||||||
|
a shared `X-Callback-Token` (`CALLBACK_TOKEN`). Request-controlled
|
||||||
|
`tenant_id`, `callback_base_url` and `continuation_flow_id` were
|
||||||
|
removed (SSRF / cross-tenant / flow-chaining hardening);
|
||||||
|
continuations now resolve from the admin-authored definition.
|
||||||
|
- Frontend: `/app/*` routes require login; approver is the session user.
|
||||||
### Added
|
### Added
|
||||||
- Landing page: status-quo problem block, 5-step process with quote,
|
- Landing page: status-quo problem block, 5-step process with quote,
|
||||||
portal overview with admin areas, AI platform parts, exception tiers,
|
portal overview with admin areas, AI platform parts, exception tiers,
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,27 @@
|
||||||
"""Flows + approvals HTTP API."""
|
"""Flows + approvals HTTP API.
|
||||||
|
|
||||||
|
Auth model: flow *management* (create/version/deploy/sync/import) is
|
||||||
|
admin-only. Browsing (list/get/input-schema) is public so the website
|
||||||
|
renders. Runs, run reads and approval reads require login and are scoped
|
||||||
|
to the session tenant. Flow *callbacks* (LFX has no session) carry a
|
||||||
|
shared X-Callback-Token instead.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import secrets
|
||||||
from collections.abc import AsyncIterator, Iterator
|
from collections.abc import AsyncIterator, Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_optional_user, resolve_tenant
|
from app.auth.deps import get_current_user, require_admin
|
||||||
from app.db import get_engine, get_session
|
from app.db import get_engine, get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
@ -127,7 +135,11 @@ def _read_task(t) -> ApprovalRead:
|
||||||
|
|
||||||
|
|
||||||
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
|
@flows_router.post("", response_model=FlowDefinitionRead, status_code=201)
|
||||||
def create_flow(data: FlowDefinitionCreate, session: Annotated[Session, Depends(get_db)]):
|
def create_flow(
|
||||||
|
data: FlowDefinitionCreate,
|
||||||
|
session: Annotated[Session, Depends(get_db)],
|
||||||
|
_admin: Annotated[User, Depends(require_admin)],
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
return _read_definition(store.create_definition(session, data))
|
return _read_definition(store.create_definition(session, data))
|
||||||
except store.ConflictError as exc:
|
except store.ConflictError as exc:
|
||||||
|
|
@ -145,6 +157,7 @@ def list_flows(session: Annotated[Session, Depends(get_db)]):
|
||||||
async def sync_flows(
|
async def sync_flows(
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Annotated[Session, Depends(get_db)],
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||||
|
_admin: Annotated[User, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
|
"""Reconcile backend/flows/*.json into the DB + LFX (add/update/delete)."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -163,7 +176,10 @@ def get_flow(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
|
||||||
|
|
||||||
@flows_router.post("/{flow_id}/versions", response_model=FlowVersionRead, status_code=201)
|
@flows_router.post("/{flow_id}/versions", response_model=FlowVersionRead, status_code=201)
|
||||||
def create_version(
|
def create_version(
|
||||||
flow_id: UUID, data: FlowVersionCreate, session: Annotated[Session, Depends(get_db)]
|
flow_id: UUID,
|
||||||
|
data: FlowVersionCreate,
|
||||||
|
session: Annotated[Session, Depends(get_db)],
|
||||||
|
_admin: Annotated[User, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
return _read_version(store.create_version(session, flow_id, data))
|
return _read_version(store.create_version(session, flow_id, data))
|
||||||
|
|
@ -174,7 +190,11 @@ def create_version(
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
|
@flows_router.get("/{flow_id}/versions", response_model=list[FlowVersionRead])
|
||||||
def list_versions(flow_id: UUID, session: Annotated[Session, Depends(get_db)]):
|
def list_versions(
|
||||||
|
flow_id: UUID,
|
||||||
|
session: Annotated[Session, Depends(get_db)],
|
||||||
|
_admin: Annotated[User, Depends(require_admin)],
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
return [_read_version(v) for v in store.list_versions(session, flow_id)]
|
return [_read_version(v) for v in store.list_versions(session, flow_id)]
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
|
|
@ -187,6 +207,7 @@ async def deploy_version(
|
||||||
version_no: int,
|
version_no: int,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Annotated[Session, Depends(get_db)],
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||||
|
_admin: Annotated[User, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
version = store.get_version_by_no(session, flow_id, version_no)
|
version = store.get_version_by_no(session, flow_id, version_no)
|
||||||
|
|
@ -223,30 +244,32 @@ async def run_flow(
|
||||||
data: RunRequest,
|
data: RunRequest,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Annotated[Session, Depends(get_db)],
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||||
user: Annotated[User | None, Depends(get_optional_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
stream: bool = Query(default=False),
|
stream: bool = Query(default=False),
|
||||||
):
|
):
|
||||||
# Tenant comes from the session; the body param stays as a deprecated
|
# Tenant, callback URL and continuation always come from the server:
|
||||||
# fallback for unauthenticated callers.
|
# the session, the settings, and the admin-authored definition.
|
||||||
data = data.model_copy(update={"tenant_id": resolve_tenant(user, data.tenant_id)})
|
tenant_id = user.tenant_id
|
||||||
try:
|
try:
|
||||||
|
definition = store.get_definition(session, flow_id)
|
||||||
version = _resolve_version(session, flow_id, data.version_no)
|
version = _resolve_version(session, flow_id, data.version_no)
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except store.InvalidError as exc:
|
except store.InvalidError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
if stream:
|
if stream:
|
||||||
return await _run_stream(session, lfx, version, data)
|
return await _run_stream(session, lfx, version, data, user, definition)
|
||||||
try:
|
try:
|
||||||
run = await runner.execute_run(
|
run = await runner.execute_run(
|
||||||
session,
|
session,
|
||||||
lfx,
|
lfx,
|
||||||
version,
|
version,
|
||||||
payload=data.input,
|
payload=data.input,
|
||||||
tenant_id=data.tenant_id,
|
tenant_id=tenant_id,
|
||||||
session_id=data.session_id,
|
session_id=data.session_id,
|
||||||
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
callback_base_url=settings.callback_base_url,
|
||||||
continuation_flow_id=data.continuation_flow_id,
|
continuation_flow_id=definition.continuation_flow_id,
|
||||||
|
callback_token=settings.callback_token,
|
||||||
)
|
)
|
||||||
except LfxNotFound as exc:
|
except LfxNotFound as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
||||||
|
|
@ -257,21 +280,24 @@ async def run_flow(
|
||||||
return _read_run(run)
|
return _read_run(run)
|
||||||
|
|
||||||
|
|
||||||
async def _run_stream(session: Session, lfx: LfxClient, version, data: RunRequest):
|
async def _run_stream(
|
||||||
|
session: Session, lfx: LfxClient, version, data: RunRequest, user: User, definition
|
||||||
|
):
|
||||||
if not version.lfx_flow_id:
|
if not version.lfx_flow_id:
|
||||||
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
|
raise HTTPException(status_code=422, detail=f"version {version.id} is not deployed to LFX")
|
||||||
run = store.create_run(
|
run = store.create_run(
|
||||||
session,
|
session,
|
||||||
version,
|
version,
|
||||||
input=data.input,
|
input=data.input,
|
||||||
tenant_id=data.tenant_id,
|
tenant_id=user.tenant_id,
|
||||||
session_id=data.session_id,
|
session_id=data.session_id,
|
||||||
)
|
)
|
||||||
envelope = runner.build_envelope(
|
envelope = runner.build_envelope(
|
||||||
payload=data.input,
|
payload=data.input,
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
callback_base_url=settings.callback_base_url,
|
||||||
continuation_flow_id=data.continuation_flow_id,
|
continuation_flow_id=definition.continuation_flow_id,
|
||||||
|
callback_token=settings.callback_token,
|
||||||
)
|
)
|
||||||
run_id = run.id
|
run_id = run.id
|
||||||
lfx_flow_id = version.lfx_flow_id
|
lfx_flow_id = version.lfx_flow_id
|
||||||
|
|
@ -314,21 +340,20 @@ async def _run_stream(session: Session, lfx: LfxClient, version, data: RunReques
|
||||||
def list_runs(
|
def list_runs(
|
||||||
flow_id: UUID,
|
flow_id: UUID,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Annotated[Session, Depends(get_db)],
|
||||||
user: Annotated[User | None, Depends(get_optional_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
tenant_id: str | None = Query(default=None),
|
|
||||||
):
|
):
|
||||||
# Authenticated callers are scoped to their session tenant unless they
|
# Strict tenant scoping: the session decides, query params cannot widen.
|
||||||
# pass an explicit tenant (deprecated fallback for unauthenticated use).
|
|
||||||
if user is not None and tenant_id is None:
|
|
||||||
tenant_id = user.tenant_id
|
|
||||||
try:
|
try:
|
||||||
return [_read_run(r) for r in store.list_runs(session, flow_id, tenant_id)]
|
return [_read_run(r) for r in store.list_runs(session, flow_id, user.tenant_id)]
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@flows_router.get("/langflow/available")
|
@flows_router.get("/langflow/available")
|
||||||
async def list_langflow_flows(langflow: Annotated[LangflowClient, Depends(get_langflow)]):
|
async def list_langflow_flows(
|
||||||
|
langflow: Annotated[LangflowClient, Depends(get_langflow)],
|
||||||
|
_admin: Annotated[User, Depends(require_admin)],
|
||||||
|
):
|
||||||
"""Browse flows on the Langflow authoring server (admin import source)."""
|
"""Browse flows on the Langflow authoring server (admin import source)."""
|
||||||
try:
|
try:
|
||||||
return await langflow.list_flows()
|
return await langflow.list_flows()
|
||||||
|
|
@ -344,6 +369,7 @@ async def import_flow(
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Annotated[Session, Depends(get_db)],
|
||||||
langflow: Annotated[LangflowClient, Depends(get_langflow)],
|
langflow: Annotated[LangflowClient, Depends(get_langflow)],
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||||
|
_admin: Annotated[User, Depends(require_admin)],
|
||||||
):
|
):
|
||||||
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
|
"""Import a flow from Langflow: export -> version -> (deploy to LFX)."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -401,24 +427,38 @@ def get_input_schema(
|
||||||
def get_run(
|
def get_run(
|
||||||
run_id: UUID,
|
run_id: UUID,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Annotated[Session, Depends(get_db)],
|
||||||
user: Annotated[User | None, Depends(get_optional_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
tenant_id: str | None = Query(default=None),
|
|
||||||
):
|
):
|
||||||
"""Read a run. With tenant_id, cross-tenant reads 404 (v1 scoping)."""
|
"""Read a run. Other tenants' runs 404 (strict session scoping)."""
|
||||||
if user is not None and tenant_id is None:
|
|
||||||
tenant_id = user.tenant_id
|
|
||||||
try:
|
try:
|
||||||
run = store.get_run(session, run_id)
|
run = store.get_run(session, run_id)
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
if tenant_id is not None and run.tenant_id != tenant_id:
|
if run.tenant_id != user.tenant_id:
|
||||||
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"run {run_id} not found")
|
||||||
return _read_run(run)
|
return _read_run(run)
|
||||||
|
|
||||||
|
|
||||||
|
CALLBACK_TOKEN_HEADER = "X-Callback-Token"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_callback_token(token: str | None) -> None:
|
||||||
|
"""LFX-served flows prove they carry our run envelope (no session there)."""
|
||||||
|
expected = settings.callback_token
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(status_code=503, detail="flow callbacks are disabled")
|
||||||
|
if not token or not secrets.compare_digest(token, expected):
|
||||||
|
raise HTTPException(status_code=401, detail="invalid callback token")
|
||||||
|
|
||||||
|
|
||||||
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
|
@approvals_router.post("", response_model=ApprovalRead, status_code=201)
|
||||||
def create_approval(data: ApprovalCreate, session: Annotated[Session, Depends(get_db)]):
|
def create_approval(
|
||||||
|
data: ApprovalCreate,
|
||||||
|
session: Annotated[Session, Depends(get_db)],
|
||||||
|
callback_token: Annotated[str | None, Header(alias=CALLBACK_TOKEN_HEADER)] = None,
|
||||||
|
):
|
||||||
"""Flow callback contract: report a terminal approval state for a run."""
|
"""Flow callback contract: report a terminal approval state for a run."""
|
||||||
|
_check_callback_token(callback_token)
|
||||||
try:
|
try:
|
||||||
return _read_task(store.create_approval_task(session, data))
|
return _read_task(store.create_approval_task(session, data))
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
|
|
@ -432,12 +472,9 @@ def create_approval(data: ApprovalCreate, session: Annotated[Session, Depends(ge
|
||||||
@approvals_router.get("", response_model=list[ApprovalRead])
|
@approvals_router.get("", response_model=list[ApprovalRead])
|
||||||
def list_approvals(
|
def list_approvals(
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Annotated[Session, Depends(get_db)],
|
||||||
user: Annotated[User | None, Depends(get_optional_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
tenant_id: str | None = Query(default=None),
|
|
||||||
):
|
):
|
||||||
if user is not None and tenant_id is None:
|
return [_read_task(t) for t in store.list_approval_tasks(session, user.tenant_id)]
|
||||||
tenant_id = user.tenant_id
|
|
||||||
return [_read_task(t) for t in store.list_approval_tasks(session, tenant_id)]
|
|
||||||
|
|
||||||
|
|
||||||
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
|
@approvals_router.post("/{task_id}/decision", response_model=DecisionRead)
|
||||||
|
|
@ -446,8 +483,13 @@ async def decide_approval(
|
||||||
data: DecisionRequest,
|
data: DecisionRequest,
|
||||||
session: Annotated[Session, Depends(get_db)],
|
session: Annotated[Session, Depends(get_db)],
|
||||||
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
lfx: Annotated[LfxClient, Depends(get_lfx)],
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
):
|
):
|
||||||
"""Decide an open task. Approve starts the continuation flow (if any)."""
|
"""Decide an open task. Approve starts the continuation flow (if any).
|
||||||
|
|
||||||
|
Identity comes from the session: the approver is the logged-in user,
|
||||||
|
and the continuation is the task's own (never client-chosen).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
task = store.get_task(session, task_id)
|
task = store.get_task(session, task_id)
|
||||||
except store.NotFoundError as exc:
|
except store.NotFoundError as exc:
|
||||||
|
|
@ -467,7 +509,7 @@ async def decide_approval(
|
||||||
session,
|
session,
|
||||||
task,
|
task,
|
||||||
decision="approved" if data.decision == "approve" else "rejected",
|
decision="approved" if data.decision == "approve" else "rejected",
|
||||||
approver=data.approver,
|
approver=user.email,
|
||||||
note=data.note,
|
note=data.note,
|
||||||
)
|
)
|
||||||
except store.ConflictError as exc:
|
except store.ConflictError as exc:
|
||||||
|
|
@ -489,7 +531,8 @@ async def decide_approval(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
tenant_id=task.tenant_id,
|
tenant_id=task.tenant_id,
|
||||||
session_id=f"continuation-of-{task.run_id}",
|
session_id=f"continuation-of-{task.run_id}",
|
||||||
callback_base_url=data.callback_base_url or settings.callback_base_url,
|
callback_base_url=settings.callback_base_url,
|
||||||
|
callback_token=settings.callback_token,
|
||||||
)
|
)
|
||||||
except LfxNotFound as exc:
|
except LfxNotFound as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
raise HTTPException(status_code=502, detail=f"LFX drift: {exc}") from exc
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ def build_envelope(
|
||||||
run_id: UUID,
|
run_id: UUID,
|
||||||
callback_base_url: str,
|
callback_base_url: str,
|
||||||
continuation_flow_id: UUID | None,
|
continuation_flow_id: UUID | None,
|
||||||
|
callback_token: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
|
|
@ -27,6 +28,7 @@ def build_envelope(
|
||||||
"run_id": str(run_id),
|
"run_id": str(run_id),
|
||||||
"callback_url": callback_base_url.rstrip("/"),
|
"callback_url": callback_base_url.rstrip("/"),
|
||||||
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
"continuation_flow_id": (str(continuation_flow_id) if continuation_flow_id else None),
|
||||||
|
"callback_token": callback_token,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -50,6 +52,7 @@ async def execute_run(
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
callback_base_url: str,
|
callback_base_url: str,
|
||||||
continuation_flow_id: UUID | None = None,
|
continuation_flow_id: UUID | None = None,
|
||||||
|
callback_token: str = "",
|
||||||
) -> FlowRun:
|
) -> FlowRun:
|
||||||
"""Run a deployed version to a terminal state (no interrupts).
|
"""Run a deployed version to a terminal state (no interrupts).
|
||||||
|
|
||||||
|
|
@ -68,6 +71,7 @@ async def execute_run(
|
||||||
run_id=run.id,
|
run_id=run.id,
|
||||||
callback_base_url=callback_base_url,
|
callback_base_url=callback_base_url,
|
||||||
continuation_flow_id=continuation_flow_id,
|
continuation_flow_id=continuation_flow_id,
|
||||||
|
callback_token=callback_token,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = await lfx.run_flow(
|
result = await lfx.run_flow(
|
||||||
|
|
|
||||||
|
|
@ -52,16 +52,16 @@ class InputSchemaRead(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class RunRequest(BaseModel):
|
class RunRequest(BaseModel):
|
||||||
"""Start a flow run. `input` is the domain payload handed to the flow."""
|
"""Start a flow run. `input` is the domain payload handed to the flow.
|
||||||
|
|
||||||
|
Tenant, callback URL and continuation come from the server (session /
|
||||||
|
settings / task), never from the caller — they must not be
|
||||||
|
client-controllable (SSRF / cross-flow chaining).
|
||||||
|
"""
|
||||||
|
|
||||||
input: dict = Field(default_factory=dict)
|
input: dict = Field(default_factory=dict)
|
||||||
version_no: int | None = None
|
version_no: int | None = None
|
||||||
tenant_id: str = Field(default="default", max_length=200)
|
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
# Base URL the flow uses for callbacks (e.g. approval tasks).
|
|
||||||
# Defaults to settings.callback_base_url; tests point it at a live server.
|
|
||||||
callback_base_url: str | None = None
|
|
||||||
continuation_flow_id: UUID | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RunRead(BaseModel):
|
class RunRead(BaseModel):
|
||||||
|
|
@ -115,10 +115,10 @@ class FlowImportRead(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class DecisionRequest(BaseModel):
|
class DecisionRequest(BaseModel):
|
||||||
|
"""Decide a task. Identity comes from the session, not the body."""
|
||||||
|
|
||||||
decision: str = Field(pattern="^(approve|reject)$")
|
decision: str = Field(pattern="^(approve|reject)$")
|
||||||
approver: str = Field(default="", max_length=200)
|
|
||||||
note: str = Field(default="", max_length=2000)
|
note: str = Field(default="", max_length=2000)
|
||||||
callback_base_url: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class DecisionRead(BaseModel):
|
class DecisionRead(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ class Settings(BaseSettings):
|
||||||
# Base URL flows use for callbacks into this API. Must be reachable
|
# Base URL flows use for callbacks into this API. Must be reachable
|
||||||
# FROM the LFX process (compose: http://api:8000).
|
# FROM the LFX process (compose: http://api:8000).
|
||||||
callback_base_url: str = "http://localhost:8000"
|
callback_base_url: str = "http://localhost:8000"
|
||||||
|
# Shared secret that authenticates flow callbacks (POST /api/approvals).
|
||||||
|
# The runner injects it into every run envelope; LFX-served flows send
|
||||||
|
# it back as X-Callback-Token. Empty disables callbacks (503) — set a
|
||||||
|
# long random value in prod (.env CALLBACK_TOKEN).
|
||||||
|
callback_token: str = ""
|
||||||
|
|
||||||
# --- 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).
|
# Public frontend URL (magic links point at /login?token= there).
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@
|
||||||
"list": false,
|
"list": false,
|
||||||
"show": true,
|
"show": true,
|
||||||
"multiline": true,
|
"multiline": true,
|
||||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
|
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
|
||||||
"fileTypes": [],
|
"fileTypes": [],
|
||||||
"file_path": "",
|
"file_path": "",
|
||||||
"password": false,
|
"password": false,
|
||||||
|
|
@ -399,6 +399,20 @@
|
||||||
"allows_loop": false,
|
"allows_loop": false,
|
||||||
"group_outputs": false,
|
"group_outputs": false,
|
||||||
"tool_mode": true
|
"tool_mode": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"types": [
|
||||||
|
"Message"
|
||||||
|
],
|
||||||
|
"selected": "Message",
|
||||||
|
"name": "callback_token",
|
||||||
|
"display_name": "Callback Token",
|
||||||
|
"method": "get_callback_token",
|
||||||
|
"value": "__UNDEFINED__",
|
||||||
|
"cache": true,
|
||||||
|
"allows_loop": false,
|
||||||
|
"group_outputs": false,
|
||||||
|
"tool_mode": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"field_order": [
|
"field_order": [
|
||||||
|
|
@ -409,7 +423,7 @@
|
||||||
"edited": false,
|
"edited": false,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"module": "custom_components.envelope",
|
"module": "custom_components.envelope",
|
||||||
"code_hash": "2b0bd3c3d529",
|
"code_hash": "02e69613e210",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"total_dependencies": 1,
|
"total_dependencies": 1,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -858,6 +872,32 @@
|
||||||
"node": {
|
"node": {
|
||||||
"template": {
|
"template": {
|
||||||
"_type": "Component",
|
"_type": "Component",
|
||||||
|
"callback_token": {
|
||||||
|
"tool_mode": false,
|
||||||
|
"trace_as_input": true,
|
||||||
|
"trace_as_metadata": true,
|
||||||
|
"load_from_db": false,
|
||||||
|
"list": false,
|
||||||
|
"list_add_label": "Add More",
|
||||||
|
"override_skip": false,
|
||||||
|
"required": false,
|
||||||
|
"placeholder": "",
|
||||||
|
"show": true,
|
||||||
|
"name": "callback_token",
|
||||||
|
"value": "",
|
||||||
|
"display_name": "Callback Token",
|
||||||
|
"advanced": false,
|
||||||
|
"api_editable": false,
|
||||||
|
"input_types": [
|
||||||
|
"Message"
|
||||||
|
],
|
||||||
|
"dynamic": false,
|
||||||
|
"info": "",
|
||||||
|
"title_case": false,
|
||||||
|
"track_in_telemetry": false,
|
||||||
|
"type": "str",
|
||||||
|
"_input_type": "MessageTextInput"
|
||||||
|
},
|
||||||
"callback_url": {
|
"callback_url": {
|
||||||
"tool_mode": false,
|
"tool_mode": false,
|
||||||
"trace_as_input": true,
|
"trace_as_input": true,
|
||||||
|
|
@ -891,7 +931,7 @@
|
||||||
"list": false,
|
"list": false,
|
||||||
"show": true,
|
"show": true,
|
||||||
"multiline": true,
|
"multiline": true,
|
||||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
||||||
"fileTypes": [],
|
"fileTypes": [],
|
||||||
"file_path": "",
|
"file_path": "",
|
||||||
"password": false,
|
"password": false,
|
||||||
|
|
@ -1040,6 +1080,7 @@
|
||||||
"trigger",
|
"trigger",
|
||||||
"run_id",
|
"run_id",
|
||||||
"callback_url",
|
"callback_url",
|
||||||
|
"callback_token",
|
||||||
"continuation_flow_id",
|
"continuation_flow_id",
|
||||||
"facts"
|
"facts"
|
||||||
],
|
],
|
||||||
|
|
@ -1048,7 +1089,7 @@
|
||||||
"edited": false,
|
"edited": false,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"module": "custom_components.approval_callback",
|
"module": "custom_components.approval_callback",
|
||||||
"code_hash": "a334376410f7",
|
"code_hash": "28b2f5894566",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"total_dependencies": 2,
|
"total_dependencies": 2,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -1804,6 +1845,34 @@
|
||||||
"target": "CustomComponent-callback",
|
"target": "CustomComponent-callback",
|
||||||
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"animated": false,
|
||||||
|
"className": "",
|
||||||
|
"data": {
|
||||||
|
"sourceHandle": {
|
||||||
|
"dataType": "CustomComponent",
|
||||||
|
"id": "CustomComponent-envelope",
|
||||||
|
"name": "callback_token",
|
||||||
|
"output_types": [
|
||||||
|
"Message"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targetHandle": {
|
||||||
|
"fieldName": "callback_token",
|
||||||
|
"id": "CustomComponent-callback",
|
||||||
|
"inputTypes": [
|
||||||
|
"Message"
|
||||||
|
],
|
||||||
|
"type": "str"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
|
||||||
|
"selected": false,
|
||||||
|
"source": "CustomComponent-envelope",
|
||||||
|
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
|
||||||
|
"target": "CustomComponent-callback",
|
||||||
|
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"animated": false,
|
"animated": false,
|
||||||
"className": "",
|
"className": "",
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,11 @@ class EnvelopeComponent(Component):
|
||||||
name="continuation",
|
name="continuation",
|
||||||
method="get_continuation",
|
method="get_continuation",
|
||||||
),
|
),
|
||||||
|
Output(
|
||||||
|
display_name="Callback Token",
|
||||||
|
name="callback_token",
|
||||||
|
method="get_callback_token",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def _text(self, value) -> str:
|
def _text(self, value) -> str:
|
||||||
|
|
@ -333,6 +338,9 @@ class EnvelopeComponent(Component):
|
||||||
def get_continuation(self) -> Message:
|
def get_continuation(self) -> Message:
|
||||||
value = self._env().get("continuation_flow_id")
|
value = self._env().get("continuation_flow_id")
|
||||||
return Message(text=str(value) if value else "")
|
return Message(text=str(value) if value else "")
|
||||||
|
|
||||||
|
def get_callback_token(self) -> Message:
|
||||||
|
return Message(text=str(self._env().get("callback_token", "")))
|
||||||
"""
|
"""
|
||||||
|
|
||||||
APPROVAL_CALLBACK_CODE = """from lfx.custom import Component
|
APPROVAL_CALLBACK_CODE = """from lfx.custom import Component
|
||||||
|
|
@ -348,6 +356,10 @@ class ApprovalCallbackComponent(Component):
|
||||||
MessageTextInput(name="trigger", display_name="Trigger"),
|
MessageTextInput(name="trigger", display_name="Trigger"),
|
||||||
MessageTextInput(name="run_id", display_name="Run ID"),
|
MessageTextInput(name="run_id", display_name="Run ID"),
|
||||||
MessageTextInput(name="callback_url", display_name="Callback URL"),
|
MessageTextInput(name="callback_url", display_name="Callback URL"),
|
||||||
|
MessageTextInput(
|
||||||
|
name="callback_token",
|
||||||
|
display_name="Callback Token",
|
||||||
|
),
|
||||||
MessageTextInput(
|
MessageTextInput(
|
||||||
name="continuation_flow_id",
|
name="continuation_flow_id",
|
||||||
display_name="Continuation Flow ID",
|
display_name="Continuation Flow ID",
|
||||||
|
|
@ -384,8 +396,9 @@ class ApprovalCallbackComponent(Component):
|
||||||
"payload": {"verdict": trigger, "facts": facts},
|
"payload": {"verdict": trigger, "facts": facts},
|
||||||
"continuation_flow_id": self._text(self.continuation_flow_id) or None,
|
"continuation_flow_id": self._text(self.continuation_flow_id) or None,
|
||||||
}
|
}
|
||||||
|
headers = {"X-Callback-Token": self._text(self.callback_token)}
|
||||||
try:
|
try:
|
||||||
response = httpx.post(url, json=body, timeout=10.0)
|
response = httpx.post(url, json=body, headers=headers, timeout=10.0)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return Message(text=f"CALLBACK_FAILED: {exc}")
|
return Message(text=f"CALLBACK_FAILED: {exc}")
|
||||||
|
|
@ -487,6 +500,7 @@ def build_approval_handoff() -> dict:
|
||||||
edge(router, "true_result", callback, "trigger"),
|
edge(router, "true_result", callback, "trigger"),
|
||||||
edge(envelope, "run_id", callback, "run_id"),
|
edge(envelope, "run_id", callback, "run_id"),
|
||||||
edge(envelope, "callback_url", callback, "callback_url"),
|
edge(envelope, "callback_url", callback, "callback_url"),
|
||||||
|
edge(envelope, "callback_token", callback, "callback_token"),
|
||||||
edge(envelope, "continuation", callback, "continuation_flow_id"),
|
edge(envelope, "continuation", callback, "continuation_flow_id"),
|
||||||
edge(envelope, "payload", callback, "facts"),
|
edge(envelope, "payload", callback, "facts"),
|
||||||
edge(callback, "result", out_pending, "input_value"),
|
edge(callback, "result", out_pending, "input_value"),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
@ -18,11 +20,21 @@ import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BACKEND = Path(__file__).resolve().parent.parent
|
BACKEND = Path(__file__).resolve().parent.parent
|
||||||
VENV_BIN = BACKEND / ".venv" / "bin"
|
# Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH.
|
||||||
|
VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin")
|
||||||
FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
|
FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
|
||||||
|
|
||||||
LFX_PORT = 6004
|
|
||||||
API_PORT = 8000
|
def _free_port() -> int:
|
||||||
|
# Ephemeral ports: the dev API/LFX may already occupy the defaults
|
||||||
|
# (e.g. when smoke runs inside the api container itself).
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
LFX_PORT = _free_port()
|
||||||
|
API_PORT = _free_port()
|
||||||
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
|
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -42,6 +54,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
|
||||||
|
# planted directly in the throwaway DB (see main()).
|
||||||
|
c.cookies.set("kirche_session", "smoke-session", 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()
|
||||||
|
|
@ -68,8 +83,36 @@ def main() -> None:
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
cfg.set_main_option("script_location", str(BACKEND / "migrations"))
|
cfg.set_main_option("script_location", str(BACKEND / "migrations"))
|
||||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||||
|
# migrations/env.py takes the URL from settings (not the ini value):
|
||||||
|
# point this process at the throwaway DB before migrating.
|
||||||
|
from app.settings import settings as app_settings
|
||||||
|
|
||||||
|
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
|
||||||
|
# no SMTP in smoke to deliver a magic link.
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlmodel import Session, create_engine
|
||||||
|
|
||||||
|
from app.models import User, UserSession
|
||||||
|
|
||||||
|
engine = create_engine(db_url)
|
||||||
|
with Session(engine) as s:
|
||||||
|
user = User(name="smoke", email="smoke@example.com", role="admin")
|
||||||
|
s.add(user)
|
||||||
|
s.flush()
|
||||||
|
s.add(
|
||||||
|
UserSession(
|
||||||
|
id="smoke-session",
|
||||||
|
user_id=user.id,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
env = {
|
env = {
|
||||||
**os.environ,
|
**os.environ,
|
||||||
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
|
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
|
||||||
|
|
@ -80,6 +123,7 @@ def main() -> None:
|
||||||
"FLOWS_WATCH_ENABLED": "false",
|
"FLOWS_WATCH_ENABLED": "false",
|
||||||
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
|
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
|
||||||
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
|
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
|
||||||
|
"CALLBACK_TOKEN": "smoke-callback-token",
|
||||||
}
|
}
|
||||||
lfx = subprocess.Popen(
|
lfx = subprocess.Popen(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-"))
|
||||||
# Must precede all `app.*` imports: Settings is instantiated at import time.
|
# Must precede all `app.*` imports: Settings is instantiated at import time.
|
||||||
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
|
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.
|
||||||
|
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
|
||||||
# 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"
|
||||||
|
|
@ -81,6 +83,48 @@ async def client():
|
||||||
yield c
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def login(client, session):
|
||||||
|
"""Log the test client in as a fresh user: ``await login()`` (admin)
|
||||||
|
or ``await login(role="member", tenant_id="pfarramt-a")``."""
|
||||||
|
|
||||||
|
async def _login(
|
||||||
|
email: str = "admin@example.com",
|
||||||
|
role: str = "admin",
|
||||||
|
tenant_id: str = "default",
|
||||||
|
):
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.auth.deps import SESSION_COOKIE
|
||||||
|
from app.models import User, UserSession
|
||||||
|
|
||||||
|
# Idempotent: re-login updates the existing row (tests switch
|
||||||
|
# identity mid-test by calling login() again).
|
||||||
|
user = session.exec(select(User).where(User.email == email)).first()
|
||||||
|
if user is None:
|
||||||
|
user = User(name="Test", email=email, role=role, tenant_id=tenant_id)
|
||||||
|
session.add(user)
|
||||||
|
else:
|
||||||
|
user.role = role
|
||||||
|
user.tenant_id = tenant_id
|
||||||
|
session.add(user)
|
||||||
|
session.flush()
|
||||||
|
user_session = UserSession(
|
||||||
|
id=f"test-session-{uuid4().hex}",
|
||||||
|
user_id=user.id,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(days=1),
|
||||||
|
)
|
||||||
|
session.add(user_session)
|
||||||
|
session.commit()
|
||||||
|
client.cookies.set(SESSION_COOKIE, user_session.id)
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _login
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def lfx_server(tmp_path_factory):
|
def lfx_server(tmp_path_factory):
|
||||||
"""Real `lfx serve` subprocess on a free port; points settings at it.
|
"""Real `lfx serve` subprocess on a free port; points settings at it.
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,7 @@
|
||||||
"list": false,
|
"list": false,
|
||||||
"show": true,
|
"show": true,
|
||||||
"multiline": true,
|
"multiline": true,
|
||||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n",
|
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\nimport json\n\n\nclass EnvelopeComponent(Component):\n display_name = \"Envelope\"\n description = \"Zerlegt die Eingabe-Huelle in Payload und Laufzeit-Kontext.\"\n\n inputs = [\n MessageTextInput(name=\"raw\", display_name=\"Raw Input\"),\n ]\n outputs = [\n Output(display_name=\"Payload\", name=\"payload\", method=\"get_payload\"),\n Output(display_name=\"Run ID\", name=\"run_id\", method=\"get_run_id\"),\n Output(\n display_name=\"Callback URL\",\n name=\"callback_url\",\n method=\"get_callback_url\",\n ),\n Output(\n display_name=\"Continuation\",\n name=\"continuation\",\n method=\"get_continuation\",\n ),\n Output(\n display_name=\"Callback Token\",\n name=\"callback_token\",\n method=\"get_callback_token\",\n ),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def _env(self) -> dict:\n if not hasattr(self, \"_env_cache\"):\n try:\n self._env_cache = json.loads(self._text(self.raw) or \"{}\")\n except Exception:\n self._env_cache = {}\n if not isinstance(self._env_cache, dict):\n self._env_cache = {}\n return self._env_cache\n\n def get_payload(self) -> Message:\n return Message(text=json.dumps(self._env().get(\"payload\", {})))\n\n def get_run_id(self) -> Message:\n return Message(text=str(self._env().get(\"run_id\", \"\")))\n\n def get_callback_url(self) -> Message:\n return Message(text=str(self._env().get(\"callback_url\", \"\")))\n\n def get_continuation(self) -> Message:\n value = self._env().get(\"continuation_flow_id\")\n return Message(text=str(value) if value else \"\")\n\n def get_callback_token(self) -> Message:\n return Message(text=str(self._env().get(\"callback_token\", \"\")))\n",
|
||||||
"fileTypes": [],
|
"fileTypes": [],
|
||||||
"file_path": "",
|
"file_path": "",
|
||||||
"password": false,
|
"password": false,
|
||||||
|
|
@ -399,6 +399,20 @@
|
||||||
"allows_loop": false,
|
"allows_loop": false,
|
||||||
"group_outputs": false,
|
"group_outputs": false,
|
||||||
"tool_mode": true
|
"tool_mode": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"types": [
|
||||||
|
"Message"
|
||||||
|
],
|
||||||
|
"selected": "Message",
|
||||||
|
"name": "callback_token",
|
||||||
|
"display_name": "Callback Token",
|
||||||
|
"method": "get_callback_token",
|
||||||
|
"value": "__UNDEFINED__",
|
||||||
|
"cache": true,
|
||||||
|
"allows_loop": false,
|
||||||
|
"group_outputs": false,
|
||||||
|
"tool_mode": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"field_order": [
|
"field_order": [
|
||||||
|
|
@ -409,7 +423,7 @@
|
||||||
"edited": false,
|
"edited": false,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"module": "custom_components.envelope",
|
"module": "custom_components.envelope",
|
||||||
"code_hash": "2b0bd3c3d529",
|
"code_hash": "02e69613e210",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"total_dependencies": 1,
|
"total_dependencies": 1,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -858,6 +872,32 @@
|
||||||
"node": {
|
"node": {
|
||||||
"template": {
|
"template": {
|
||||||
"_type": "Component",
|
"_type": "Component",
|
||||||
|
"callback_token": {
|
||||||
|
"tool_mode": false,
|
||||||
|
"trace_as_input": true,
|
||||||
|
"trace_as_metadata": true,
|
||||||
|
"load_from_db": false,
|
||||||
|
"list": false,
|
||||||
|
"list_add_label": "Add More",
|
||||||
|
"override_skip": false,
|
||||||
|
"required": false,
|
||||||
|
"placeholder": "",
|
||||||
|
"show": true,
|
||||||
|
"name": "callback_token",
|
||||||
|
"value": "",
|
||||||
|
"display_name": "Callback Token",
|
||||||
|
"advanced": false,
|
||||||
|
"api_editable": false,
|
||||||
|
"input_types": [
|
||||||
|
"Message"
|
||||||
|
],
|
||||||
|
"dynamic": false,
|
||||||
|
"info": "",
|
||||||
|
"title_case": false,
|
||||||
|
"track_in_telemetry": false,
|
||||||
|
"type": "str",
|
||||||
|
"_input_type": "MessageTextInput"
|
||||||
|
},
|
||||||
"callback_url": {
|
"callback_url": {
|
||||||
"tool_mode": false,
|
"tool_mode": false,
|
||||||
"trace_as_input": true,
|
"trace_as_input": true,
|
||||||
|
|
@ -891,7 +931,7 @@
|
||||||
"list": false,
|
"list": false,
|
||||||
"show": true,
|
"show": true,
|
||||||
"multiline": true,
|
"multiline": true,
|
||||||
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n try:\n response = httpx.post(url, json=body, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
"value": "from lfx.custom import Component\nfrom lfx.io import MessageTextInput, Output\nfrom lfx.schema.message import Message\n\n\nclass ApprovalCallbackComponent(Component):\n display_name = \"Approval Callback\"\n description = \"Meldet einen Sonderfall per HTTP an unsere API und terminiert.\"\n\n inputs = [\n MessageTextInput(name=\"trigger\", display_name=\"Trigger\"),\n MessageTextInput(name=\"run_id\", display_name=\"Run ID\"),\n MessageTextInput(name=\"callback_url\", display_name=\"Callback URL\"),\n MessageTextInput(\n name=\"callback_token\",\n display_name=\"Callback Token\",\n ),\n MessageTextInput(\n name=\"continuation_flow_id\",\n display_name=\"Continuation Flow ID\",\n ),\n MessageTextInput(name=\"facts\", display_name=\"Facts\"),\n ]\n outputs = [\n Output(display_name=\"Result\", name=\"result\", method=\"notify\"),\n ]\n\n def _text(self, value) -> str:\n if value is None:\n return \"\"\n if hasattr(value, \"text\"):\n return value.text or \"\"\n return str(value)\n\n def notify(self) -> Message:\n import httpx\n import json\n\n trigger = self._text(self.trigger).strip()\n if not trigger:\n # Zweig wurde nicht genommen: kein Seiteneffekt.\n return Message(text=\"\")\n try:\n facts = json.loads(self._text(self.facts) or \"{}\")\n except Exception:\n facts = {}\n url = self._text(self.callback_url).rstrip(\"/\") + \"/api/approvals\"\n body = {\n \"run_id\": self._text(self.run_id),\n \"title\": f\"Freigabe erforderlich ({trigger})\",\n \"payload\": {\"verdict\": trigger, \"facts\": facts},\n \"continuation_flow_id\": self._text(self.continuation_flow_id) or None,\n }\n headers = {\"X-Callback-Token\": self._text(self.callback_token)}\n try:\n response = httpx.post(url, json=body, headers=headers, timeout=10.0)\n response.raise_for_status()\n except Exception as exc:\n return Message(text=f\"CALLBACK_FAILED: {exc}\")\n return Message(text=\"PENDING_HUMAN_APPROVAL\")\n",
|
||||||
"fileTypes": [],
|
"fileTypes": [],
|
||||||
"file_path": "",
|
"file_path": "",
|
||||||
"password": false,
|
"password": false,
|
||||||
|
|
@ -1040,6 +1080,7 @@
|
||||||
"trigger",
|
"trigger",
|
||||||
"run_id",
|
"run_id",
|
||||||
"callback_url",
|
"callback_url",
|
||||||
|
"callback_token",
|
||||||
"continuation_flow_id",
|
"continuation_flow_id",
|
||||||
"facts"
|
"facts"
|
||||||
],
|
],
|
||||||
|
|
@ -1048,7 +1089,7 @@
|
||||||
"edited": false,
|
"edited": false,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"module": "custom_components.approval_callback",
|
"module": "custom_components.approval_callback",
|
||||||
"code_hash": "a334376410f7",
|
"code_hash": "28b2f5894566",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"total_dependencies": 2,
|
"total_dependencies": 2,
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
|
|
@ -1804,6 +1845,34 @@
|
||||||
"target": "CustomComponent-callback",
|
"target": "CustomComponent-callback",
|
||||||
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
"targetHandle": "{œfieldNameœ: œcallback_urlœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"animated": false,
|
||||||
|
"className": "",
|
||||||
|
"data": {
|
||||||
|
"sourceHandle": {
|
||||||
|
"dataType": "CustomComponent",
|
||||||
|
"id": "CustomComponent-envelope",
|
||||||
|
"name": "callback_token",
|
||||||
|
"output_types": [
|
||||||
|
"Message"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targetHandle": {
|
||||||
|
"fieldName": "callback_token",
|
||||||
|
"id": "CustomComponent-callback",
|
||||||
|
"inputTypes": [
|
||||||
|
"Message"
|
||||||
|
],
|
||||||
|
"type": "str"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "xy-edge__{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}-{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}",
|
||||||
|
"selected": false,
|
||||||
|
"source": "CustomComponent-envelope",
|
||||||
|
"sourceHandle": "{œdataTypeœ: œCustomComponentœ, œidœ: œCustomComponent-envelopeœ, œnameœ: œcallback_tokenœ, œoutput_typesœ: [œMessageœ]}",
|
||||||
|
"target": "CustomComponent-callback",
|
||||||
|
"targetHandle": "{œfieldNameœ: œcallback_tokenœ, œidœ: œCustomComponent-callbackœ, œinputTypesœ: [œMessageœ], œtypeœ: œstrœ}"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"animated": false,
|
"animated": false,
|
||||||
"className": "",
|
"className": "",
|
||||||
|
|
|
||||||
|
|
@ -30,16 +30,14 @@ async def _version_and_deploy(client, flow_id, fixture="simple_rule.json"):
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def _handoff_task(client, live_server, continuation_flow_id=None):
|
async def _handoff_task(client, live_server, monkeypatch):
|
||||||
"""Run approval_handoff over threshold; returns (run, task)."""
|
"""Run approval_handoff over threshold; returns (run, task)."""
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||||
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
|
||||||
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
||||||
body = {
|
body = {"input": {"betrag": 250000, "schwelle": 200000}}
|
||||||
"input": {"betrag": 250000, "schwelle": 200000},
|
|
||||||
"callback_base_url": live_server,
|
|
||||||
}
|
|
||||||
if continuation_flow_id is not None:
|
|
||||||
body["continuation_flow_id"] = continuation_flow_id
|
|
||||||
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
|
run = (await client.post(f"/api/flows/{flow['id']}/run", json=body)).json()
|
||||||
assert run["status"] == "pending_approval"
|
assert run["status"] == "pending_approval"
|
||||||
tasks = (await client.get("/api/approvals")).json()
|
tasks = (await client.get("/api/approvals")).json()
|
||||||
|
|
@ -47,30 +45,55 @@ async def _handoff_task(client, live_server, continuation_flow_id=None):
|
||||||
return run, task
|
return run, task
|
||||||
|
|
||||||
|
|
||||||
async def test_approve_without_continuation(client, lfx_server, live_server):
|
async def test_approve_without_continuation(client, lfx_server, live_server, login, monkeypatch):
|
||||||
_, task = await _handoff_task(client, live_server)
|
await login()
|
||||||
|
_, task = await _handoff_task(client, live_server, monkeypatch)
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/approvals/{task['id']}/decision",
|
f"/api/approvals/{task['id']}/decision",
|
||||||
json={"decision": "approve", "approver": "sachbearbeiter", "note": "ok"},
|
json={"decision": "approve", "note": "ok"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["task"]["status"] == "approved"
|
assert body["task"]["status"] == "approved"
|
||||||
assert body["task"]["approver"] == "sachbearbeiter"
|
assert body["task"]["approver"] == "admin@example.com"
|
||||||
assert body["task"]["decided_at"] is not None
|
assert body["task"]["decided_at"] is not None
|
||||||
assert body["continuation_run"] is None
|
assert body["continuation_run"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_approve_with_continuation_runs_facts(client, lfx_server, live_server):
|
async def test_approve_with_continuation_runs_facts(
|
||||||
|
client, lfx_server, live_server, login, monkeypatch
|
||||||
|
):
|
||||||
|
await login()
|
||||||
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
|
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
|
||||||
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
|
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
|
||||||
|
|
||||||
_, task = await _handoff_task(client, live_server, continuation_flow_id=cont["id"])
|
# Continuation is authoring-time wiring on the definition (admin-only);
|
||||||
|
# run bodies cannot set it.
|
||||||
|
response = await client.post(
|
||||||
|
"/api/flows",
|
||||||
|
json={"name": f"handoff-{uuid4().hex[:8]}", "continuation_flow_id": cont["id"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
flow = response.json()
|
||||||
|
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
||||||
|
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||||
|
run = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/flows/{flow['id']}/run",
|
||||||
|
json={"input": {"betrag": 250000, "schwelle": 200000}},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
assert run["status"] == "pending_approval"
|
||||||
|
tasks = (await client.get("/api/approvals")).json()
|
||||||
|
task = next(t for t in tasks if t["run_id"] == run["id"])
|
||||||
assert task["continuation_flow_id"] == cont["id"]
|
assert task["continuation_flow_id"] == cont["id"]
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/approvals/{task['id']}/decision",
|
f"/api/approvals/{task['id']}/decision",
|
||||||
json={"decision": "approve", "approver": "chef"},
|
json={"decision": "approve"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
|
|
@ -83,21 +106,51 @@ async def test_approve_with_continuation_runs_facts(client, lfx_server, live_ser
|
||||||
assert "Sonderfall" in cont_run["output"]["result"]
|
assert "Sonderfall" in cont_run["output"]["result"]
|
||||||
|
|
||||||
|
|
||||||
async def test_reject_task(client, lfx_server, live_server):
|
async def test_client_continuation_is_ignored(client, lfx_server, live_server, login, monkeypatch):
|
||||||
_, task = await _handoff_task(client, live_server)
|
"""A run body cannot chain arbitrary flows (G)."""
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
await login()
|
||||||
|
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||||
|
cont = await _create_flow(client, f"cont-{uuid4().hex[:8]}")
|
||||||
|
await _version_and_deploy(client, cont["id"], fixture="simple_rule.json")
|
||||||
|
flow = await _create_flow(client, f"handoff-{uuid4().hex[:8]}")
|
||||||
|
await _version_and_deploy(client, flow["id"], fixture="approval_handoff.json")
|
||||||
|
run = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/flows/{flow['id']}/run",
|
||||||
|
json={
|
||||||
|
"input": {"betrag": 250000, "schwelle": 200000},
|
||||||
|
"continuation_flow_id": cont["id"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
assert run["status"] == "pending_approval"
|
||||||
|
tasks = (await client.get("/api/approvals")).json()
|
||||||
|
task = next(t for t in tasks if t["run_id"] == run["id"])
|
||||||
|
assert task["continuation_flow_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reject_task(client, lfx_server, live_server, login, monkeypatch):
|
||||||
|
await login()
|
||||||
|
_, task = await _handoff_task(client, live_server, monkeypatch)
|
||||||
|
# Any logged-in user (not just admins) can decide; identity is the session.
|
||||||
|
await login(email="chef@example.com", role="member")
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/approvals/{task['id']}/decision",
|
f"/api/approvals/{task['id']}/decision",
|
||||||
json={"decision": "reject", "approver": "chef", "note": "zu teuer"},
|
json={"decision": "reject", "note": "zu teuer"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["task"]["status"] == "rejected"
|
assert body["task"]["status"] == "rejected"
|
||||||
|
assert body["task"]["approver"] == "chef@example.com"
|
||||||
assert body["task"]["decision_note"] == "zu teuer"
|
assert body["task"]["decision_note"] == "zu teuer"
|
||||||
assert body["continuation_run"] is None
|
assert body["continuation_run"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_decision_guards(client, lfx_server, live_server):
|
async def test_decision_guards(client, lfx_server, live_server, login, monkeypatch):
|
||||||
_, task = await _handoff_task(client, live_server)
|
await login()
|
||||||
|
_, task = await _handoff_task(client, live_server, monkeypatch)
|
||||||
url = f"/api/approvals/{task['id']}/decision"
|
url = f"/api/approvals/{task['id']}/decision"
|
||||||
|
|
||||||
response = await client.post(url, json={"decision": "approve"})
|
response = await client.post(url, json={"decision": "approve"})
|
||||||
|
|
@ -108,7 +161,7 @@ async def test_decision_guards(client, lfx_server, live_server):
|
||||||
response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
|
response = await client.post(f"/api/approvals/{uuid4()}/decision", json={"decision": "approve"})
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
_, task2 = await _handoff_task(client, live_server)
|
_, task2 = await _handoff_task(client, live_server, monkeypatch)
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"}
|
f"/api/approvals/{task2['id']}/decision", json={"decision": "maybe"}
|
||||||
)
|
)
|
||||||
|
|
@ -122,9 +175,10 @@ async def test_decision_guards(client, lfx_server, live_server):
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
async def test_decision_bad_continuation_reference(client, session):
|
async def test_decision_bad_continuation_reference(client, session, login):
|
||||||
from app.flows import store as flow_store
|
from app.flows import store as flow_store
|
||||||
|
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"badcont-{uuid4().hex[:8]}")
|
||||||
version = (
|
version = (
|
||||||
await client.post(
|
await client.post(
|
||||||
|
|
@ -147,39 +201,45 @@ async def test_decision_bad_continuation_reference(client, session):
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
async def test_tenant_isolation(client, lfx_server):
|
async def test_tenant_isolation(client, lfx_server, login):
|
||||||
|
"""Strict session scoping: explicit ?tenant_id= cannot widen reads (E)."""
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"tenant-{uuid4().hex[:8]}")
|
||||||
await _version_and_deploy(client, flow["id"])
|
await _version_and_deploy(client, flow["id"])
|
||||||
|
|
||||||
for tenant in ("pfarramt-a", "pfarramt-b"):
|
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
|
||||||
response = await client.post(
|
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||||
f"/api/flows/{flow['id']}/run",
|
assert response.status_code == 200
|
||||||
json={"input": {"betrag": 1}, "tenant_id": tenant},
|
run_a = response.json()
|
||||||
)
|
assert run_a["tenant_id"] == "pfarramt-a"
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
await login(email="b@example.com", role="member", tenant_id="pfarramt-b")
|
||||||
|
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# A forged tenant_id query param is ignored: b sees only b's runs.
|
||||||
response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
|
response = await client.get(f"/api/flows/{flow['id']}/runs", params={"tenant_id": "pfarramt-a"})
|
||||||
runs_a = response.json()
|
runs_b = response.json()
|
||||||
assert len(runs_a) == 1
|
assert len(runs_b) == 1
|
||||||
assert runs_a[0]["tenant_id"] == "pfarramt-a"
|
assert runs_b[0]["tenant_id"] == "pfarramt-b"
|
||||||
|
|
||||||
response = await client.get(f"/api/flows/{flow['id']}/runs")
|
# Cross-tenant run read 404s with or without the forged param.
|
||||||
assert len(response.json()) == 2
|
response = await client.get(f"/api/flows/runs/{run_a['id']}")
|
||||||
|
|
||||||
# Cross-tenant read with scoping 404s.
|
|
||||||
response = await client.get(
|
|
||||||
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-b"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
f"/api/flows/runs/{runs_a[0]['id']}", params={"tenant_id": "pfarramt-a"}
|
f"/api/flows/runs/{run_a['id']}", params={"tenant_id": "pfarramt-a"}
|
||||||
)
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
await login(email="a@example.com", role="member", tenant_id="pfarramt-a")
|
||||||
|
response = await client.get(f"/api/flows/runs/{run_a['id']}")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
|
async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch, login):
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, f"down-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"down-{uuid4().hex[:8]}")
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/flows/{flow['id']}/versions",
|
f"/api/flows/{flow['id']}/versions",
|
||||||
|
|
@ -198,10 +258,11 @@ async def test_lfx_down_maps_to_503(client, lfx_server, monkeypatch):
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
async def test_lfx_drift_maps_to_502(client, lfx_server):
|
async def test_lfx_drift_maps_to_502(client, lfx_server, login):
|
||||||
from app.flows.lfx_client import LfxClient
|
from app.flows.lfx_client import LfxClient
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"drift-{uuid4().hex[:8]}")
|
||||||
deployed = await _version_and_deploy(client, flow["id"])
|
deployed = await _version_and_deploy(client, flow["id"])
|
||||||
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
|
await LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None).delete_flow(
|
||||||
|
|
@ -211,7 +272,8 @@ async def test_lfx_drift_maps_to_502(client, lfx_server):
|
||||||
assert response.status_code == 502
|
assert response.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server):
|
async def test_corrupt_flow_json_deploy_maps_to_422(client, lfx_server, login):
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}")
|
flow = await _create_flow(client, f"corrupt-{uuid4().hex[:8]}")
|
||||||
bogus = {
|
bogus = {
|
||||||
"name": "bogus",
|
"name": "bogus",
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,11 @@ def _export_doc(name="importiert"):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_import_creates_definition_version_and_deploys(client, lfx_server, override_langflow):
|
async def test_import_creates_definition_version_and_deploys(
|
||||||
|
client, login, lfx_server, override_langflow
|
||||||
|
):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
|
await login()
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/flows/import",
|
"/api/flows/import",
|
||||||
json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"},
|
json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"},
|
||||||
|
|
@ -46,8 +49,9 @@ async def test_import_creates_definition_version_and_deploys(client, lfx_server,
|
||||||
assert response.json()["status"] == "completed"
|
assert response.json()["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
async def test_import_same_content_is_idempotent(client, lfx_server, override_langflow):
|
async def test_import_same_content_is_idempotent(client, login, lfx_server, override_langflow):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
|
await login()
|
||||||
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||||
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
second = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||||
assert first.status_code == 201
|
assert first.status_code == 201
|
||||||
|
|
@ -57,8 +61,9 @@ async def test_import_same_content_is_idempotent(client, lfx_server, override_la
|
||||||
assert second.json()["version"]["version_no"] == 1
|
assert second.json()["version"]["version_no"] == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_import_changed_content_bumps_version(client, lfx_server, override_langflow):
|
async def test_import_changed_content_bumps_version(client, login, lfx_server, override_langflow):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
|
await login()
|
||||||
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
first = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||||
assert first.status_code == 201
|
assert first.status_code == 201
|
||||||
changed = _export_doc()
|
changed = _export_doc()
|
||||||
|
|
@ -70,12 +75,13 @@ async def test_import_changed_content_bumps_version(client, lfx_server, override
|
||||||
|
|
||||||
|
|
||||||
async def test_import_writes_canonical_file(
|
async def test_import_writes_canonical_file(
|
||||||
client, lfx_server, override_langflow, tmp_path, monkeypatch
|
client, login, lfx_server, override_langflow, tmp_path, monkeypatch
|
||||||
):
|
):
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
|
await login()
|
||||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||||
assert response.status_code == 201, response.text
|
assert response.status_code == 201, response.text
|
||||||
written = tmp_path / "importiert.json"
|
written = tmp_path / "importiert.json"
|
||||||
|
|
@ -83,8 +89,9 @@ async def test_import_writes_canonical_file(
|
||||||
assert json.loads(written.read_text())["name"] == "importiert"
|
assert json.loads(written.read_text())["name"] == "importiert"
|
||||||
|
|
||||||
|
|
||||||
async def test_import_with_explicit_name(client, lfx_server, override_langflow):
|
async def test_import_with_explicit_name(client, login, lfx_server, override_langflow):
|
||||||
override_langflow(_export_doc(name="langflow-name"))
|
override_langflow(_export_doc(name="langflow-name"))
|
||||||
|
await login()
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/flows/import",
|
"/api/flows/import",
|
||||||
json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"},
|
json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"},
|
||||||
|
|
@ -93,8 +100,9 @@ async def test_import_with_explicit_name(client, lfx_server, override_langflow):
|
||||||
assert response.json()["definition"]["name"] == "wunschname"
|
assert response.json()["definition"]["name"] == "wunschname"
|
||||||
|
|
||||||
|
|
||||||
async def test_import_without_deploy(client, override_langflow):
|
async def test_import_without_deploy(client, login, override_langflow):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
|
await login()
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/flows/import",
|
"/api/flows/import",
|
||||||
json={"langflow_flow_id": LANGFLOW_ID, "deploy": False},
|
json={"langflow_flow_id": LANGFLOW_ID, "deploy": False},
|
||||||
|
|
@ -105,20 +113,23 @@ async def test_import_without_deploy(client, override_langflow):
|
||||||
assert body["version"]["deployed_at"] is None
|
assert body["version"]["deployed_at"] is None
|
||||||
|
|
||||||
|
|
||||||
async def test_import_langflow_not_found(client, override_langflow):
|
async def test_import_langflow_not_found(client, login, override_langflow):
|
||||||
override_langflow(status=404)
|
override_langflow(status=404)
|
||||||
|
await login()
|
||||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_import_langflow_unreachable(client, override_langflow):
|
async def test_import_langflow_unreachable(client, login, override_langflow):
|
||||||
override_langflow(status=0)
|
override_langflow(status=0)
|
||||||
|
await login()
|
||||||
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
|
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
async def test_list_langflow_flows(client, override_langflow):
|
async def test_list_langflow_flows(client, login, override_langflow):
|
||||||
override_langflow(_export_doc())
|
override_langflow(_export_doc())
|
||||||
|
await login()
|
||||||
response = await client.get("/api/flows/langflow/available")
|
response = await client.get("/api/flows/langflow/available")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == [{"id": "fake-1", "name": "fake"}]
|
assert response.json() == [{"id": "fake-1", "name": "fake"}]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from uuid import UUID, uuid4
|
||||||
from app.flows.sync import lfx_id_for
|
from app.flows.sync import lfx_id_for
|
||||||
|
|
||||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||||
|
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
|
||||||
|
|
||||||
|
|
||||||
def load_fixture(name: str) -> dict:
|
def load_fixture(name: str) -> dict:
|
||||||
|
|
@ -34,7 +35,8 @@ async def _deploy(client, flow_id, version_no=1):
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
async def test_flow_crud(client):
|
async def test_flow_crud(client, login):
|
||||||
|
await login()
|
||||||
created = await _create_flow(client, "crud-flow")
|
created = await _create_flow(client, "crud-flow")
|
||||||
assert created["status"] == "active"
|
assert created["status"] == "active"
|
||||||
|
|
||||||
|
|
@ -53,7 +55,63 @@ async def test_flow_crud(client):
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_version_validation_and_numbering(client):
|
async def test_management_endpoints_require_admin(client, login):
|
||||||
|
"""Anonymous -> 401, member -> 403, admin -> through (A)."""
|
||||||
|
flow_id = uuid4()
|
||||||
|
probes = [
|
||||||
|
("POST", "/api/flows", {"name": "x"}),
|
||||||
|
("POST", "/api/flows/sync", None),
|
||||||
|
("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}),
|
||||||
|
("GET", f"/api/flows/{flow_id}/versions", None),
|
||||||
|
("POST", f"/api/flows/{flow_id}/versions/1/deploy", None),
|
||||||
|
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
|
||||||
|
("GET", "/api/flows/langflow/available", None),
|
||||||
|
]
|
||||||
|
for method, path, body in probes:
|
||||||
|
if method == "POST":
|
||||||
|
response = await client.post(path, json=body or {})
|
||||||
|
else:
|
||||||
|
response = await client.get(path)
|
||||||
|
assert response.status_code == 401, (method, path, response.text)
|
||||||
|
|
||||||
|
await login(email="member@example.com", role="member")
|
||||||
|
for method, path, body in probes:
|
||||||
|
if method == "POST":
|
||||||
|
response = await client.post(path, json=body or {})
|
||||||
|
else:
|
||||||
|
response = await client.get(path)
|
||||||
|
assert response.status_code == 403, (method, path, response.text)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_runs_and_reads_require_login(client, login):
|
||||||
|
"""Run, run reads and approval reads are login-gated (A)."""
|
||||||
|
await login()
|
||||||
|
flow = await _create_flow(client, "gated")
|
||||||
|
run_id = uuid4()
|
||||||
|
authed_paths = [
|
||||||
|
("POST", f"/api/flows/{flow['id']}/run", {"input": {}}),
|
||||||
|
("GET", f"/api/flows/{flow['id']}/runs", None),
|
||||||
|
("GET", f"/api/flows/runs/{run_id}", None),
|
||||||
|
("GET", "/api/approvals", None),
|
||||||
|
("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}),
|
||||||
|
]
|
||||||
|
# Fresh client without a session cookie.
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as anon:
|
||||||
|
for method, path, body in authed_paths:
|
||||||
|
if method == "POST":
|
||||||
|
response = await anon.post(path, json=body or {})
|
||||||
|
else:
|
||||||
|
response = await anon.get(path)
|
||||||
|
assert response.status_code == 401, (method, path, response.text)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_version_validation_and_numbering(client, login):
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, "versioned")
|
flow = await _create_flow(client, "versioned")
|
||||||
|
|
||||||
v1 = await _create_version(client, flow["id"])
|
v1 = await _create_version(client, flow["id"])
|
||||||
|
|
@ -77,7 +135,8 @@ async def test_version_validation_and_numbering(client):
|
||||||
assert [v["version_no"] for v in response.json()] == [1, 2]
|
assert [v["version_no"] for v in response.json()] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
|
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server, login):
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, "dachsanierung")
|
flow = await _create_flow(client, "dachsanierung")
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
deployed = await _deploy(client, flow["id"])
|
deployed = await _deploy(client, flow["id"])
|
||||||
|
|
@ -108,14 +167,16 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
|
||||||
assert response.json()["id"] == run["id"]
|
assert response.json()["id"] == run["id"]
|
||||||
|
|
||||||
|
|
||||||
async def test_run_undeployed_version_fails(client):
|
async def test_run_undeployed_version_fails(client, login):
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, "undeployed")
|
flow = await _create_flow(client, "undeployed")
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}})
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
async def test_run_explicit_version_no(client, lfx_server):
|
async def test_run_explicit_version_no(client, lfx_server, login):
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, "explicit-version")
|
flow = await _create_flow(client, "explicit-version")
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
|
|
@ -128,7 +189,8 @@ async def test_run_explicit_version_no(client, lfx_server):
|
||||||
assert response.json()["status"] == "completed"
|
assert response.json()["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
async def test_run_stream_events(client, lfx_server):
|
async def test_run_stream_events(client, lfx_server, login):
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, "streamed")
|
flow = await _create_flow(client, "streamed")
|
||||||
await _create_version(client, flow["id"])
|
await _create_version(client, flow["id"])
|
||||||
await _deploy(client, flow["id"])
|
await _deploy(client, flow["id"])
|
||||||
|
|
@ -161,17 +223,18 @@ async def test_run_stream_events(client, lfx_server):
|
||||||
assert response.json()["status"] == "completed"
|
assert response.json()["status"] == "completed"
|
||||||
|
|
||||||
|
|
||||||
async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
|
async def test_approval_handoff_end_to_end(client, lfx_server, live_server, login, monkeypatch):
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
await login()
|
||||||
|
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||||
flow = await _create_flow(client, "freigabe")
|
flow = await _create_flow(client, "freigabe")
|
||||||
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
||||||
await _deploy(client, flow["id"])
|
await _deploy(client, flow["id"])
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/flows/{flow['id']}/run",
|
f"/api/flows/{flow['id']}/run",
|
||||||
json={
|
json={"input": {"betrag": 250000, "schwelle": 200000}},
|
||||||
"input": {"betrag": 250000, "schwelle": 200000},
|
|
||||||
"callback_base_url": live_server,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
run = response.json()
|
run = response.json()
|
||||||
|
|
@ -188,17 +251,20 @@ async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
|
||||||
assert task["payload"]["facts"]["betrag"] == 250000
|
assert task["payload"]["facts"]["betrag"] == 250000
|
||||||
|
|
||||||
|
|
||||||
async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server):
|
async def test_approval_handoff_auto_creates_no_task(
|
||||||
|
client, lfx_server, live_server, login, monkeypatch
|
||||||
|
):
|
||||||
|
from app.settings import settings
|
||||||
|
|
||||||
|
await login()
|
||||||
|
monkeypatch.setattr(settings, "callback_base_url", live_server)
|
||||||
flow = await _create_flow(client, "freigabe-auto")
|
flow = await _create_flow(client, "freigabe-auto")
|
||||||
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
||||||
await _deploy(client, flow["id"])
|
await _deploy(client, flow["id"])
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/flows/{flow['id']}/run",
|
f"/api/flows/{flow['id']}/run",
|
||||||
json={
|
json={"input": {"betrag": 50000, "schwelle": 200000}},
|
||||||
"input": {"betrag": 50000, "schwelle": 200000},
|
|
||||||
"callback_base_url": live_server,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 200, response.text
|
assert response.status_code == 200, response.text
|
||||||
assert response.json()["status"] == "completed"
|
assert response.json()["status"] == "completed"
|
||||||
|
|
@ -208,11 +274,12 @@ async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_se
|
||||||
assert response.json() == []
|
assert response.json() == []
|
||||||
|
|
||||||
|
|
||||||
async def test_approval_callback_contract(client, session):
|
async def test_approval_callback_contract(client, session, login):
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.flows import store as flow_store
|
from app.flows import store as flow_store
|
||||||
|
|
||||||
|
await login()
|
||||||
flow = await _create_flow(client, "callback-target")
|
flow = await _create_flow(client, "callback-target")
|
||||||
version = await _create_version(client, flow["id"])
|
version = await _create_version(client, flow["id"])
|
||||||
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
||||||
|
|
@ -222,6 +289,7 @@ async def test_approval_callback_contract(client, session):
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/approvals",
|
"/api/approvals",
|
||||||
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
|
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
|
||||||
|
headers=CALLBACK_HEADERS,
|
||||||
)
|
)
|
||||||
assert response.status_code == 201, response.text
|
assert response.status_code == 201, response.text
|
||||||
task = response.json()
|
task = response.json()
|
||||||
|
|
@ -229,18 +297,46 @@ async def test_approval_callback_contract(client, session):
|
||||||
assert task["tenant_id"] == "default"
|
assert task["tenant_id"] == "default"
|
||||||
assert task["version_id"] == version["id"]
|
assert task["version_id"] == version["id"]
|
||||||
|
|
||||||
response = await client.get(f"/api/flows/runs/{run.id}")
|
response = await client.get(f"/api/flows/runs/{run.id}", headers=CALLBACK_HEADERS)
|
||||||
assert response.json()["status"] == "pending_approval"
|
assert response.json()["status"] == "pending_approval"
|
||||||
|
|
||||||
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
|
response = await client.post(
|
||||||
|
"/api/approvals", json={"run_id": str(run.id)}, headers=CALLBACK_HEADERS
|
||||||
|
)
|
||||||
assert response.status_code == 409
|
assert response.status_code == 409
|
||||||
|
|
||||||
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
"/api/approvals",
|
"/api/approvals",
|
||||||
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
|
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
|
||||||
|
headers=CALLBACK_HEADERS,
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
response = await client.post("/api/approvals", json={"run_id": str(uuid4())})
|
response = await client.post(
|
||||||
|
"/api/approvals", json={"run_id": str(uuid4())}, headers=CALLBACK_HEADERS
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_approval_callback_token_required(client, session, login):
|
||||||
|
"""Forged callbacks without the shared token are rejected (C)."""
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from app.flows import store as flow_store
|
||||||
|
|
||||||
|
await login()
|
||||||
|
flow = await _create_flow(client, "callback-gated")
|
||||||
|
version = await _create_version(client, flow["id"])
|
||||||
|
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
||||||
|
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
||||||
|
|
||||||
|
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
"/api/approvals",
|
||||||
|
json={"run_id": str(run.id)},
|
||||||
|
headers={"X-Callback-Token": "wrong"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,8 @@ def test_intake_fixture_shape():
|
||||||
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
|
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
|
||||||
|
|
||||||
|
|
||||||
async def test_intake_deploy_and_run_with_values(client, lfx_server):
|
async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
|
||||||
|
await login()
|
||||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||||
created = await client.post("/api/flows", json={"name": "intake-e2e"})
|
created = await client.post("/api/flows", json={"name": "intake-e2e"})
|
||||||
assert created.status_code == 201, created.text
|
assert created.status_code == 201, created.text
|
||||||
|
|
|
||||||
|
|
@ -170,9 +170,10 @@ async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
|
||||||
assert "form_values" not in cached["json_schema"]["properties"]
|
assert "form_values" not in cached["json_schema"]["properties"]
|
||||||
|
|
||||||
|
|
||||||
async def test_input_schema_endpoint(client, lfx_server):
|
async def test_input_schema_endpoint(client, lfx_server, login):
|
||||||
assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404
|
assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404
|
||||||
|
|
||||||
|
await login()
|
||||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||||
created = await client.post("/api/flows", json={"name": "schema-e2e"})
|
created = await client.post("/api/flows", json={"name": "schema-e2e"})
|
||||||
flow_id = created.json()["id"]
|
flow_id = created.json()["id"]
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,8 @@ async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path
|
||||||
assert definition.name == "umbenannt"
|
assert definition.name == "umbenannt"
|
||||||
|
|
||||||
|
|
||||||
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch):
|
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch, login):
|
||||||
|
await login()
|
||||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||||
_seed_dir(tmp_path)
|
_seed_dir(tmp_path)
|
||||||
response = await client.post("/api/flows/sync")
|
response = await client.post("/api/flows/sync")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Link, Route, Routes, useNavigate } from "react-router-dom";
|
import { Link, Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Anchor,
|
Anchor,
|
||||||
AppShell,
|
AppShell,
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
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";
|
||||||
import StartPage from "./pages/StartPage";
|
import StartPage from "./pages/StartPage";
|
||||||
|
|
@ -66,6 +67,21 @@ function Nav() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { me, loading } = useAuth();
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Center mt="xl">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Runs, run history and approvals are login-gated server-side;
|
||||||
|
// anonymous visitors go to the magic-link login.
|
||||||
|
if (!me) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function Shell() {
|
function Shell() {
|
||||||
const { loading } = useAuth();
|
const { loading } = useAuth();
|
||||||
|
|
||||||
|
|
@ -103,9 +119,30 @@ function Shell() {
|
||||||
<Route path="/" element={<StartPage />} />
|
<Route path="/" element={<StartPage />} />
|
||||||
<Route path="/access" element={<AccessPage />} />
|
<Route path="/access" element={<AccessPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/app/workflows" element={<WorkflowsPage />} />
|
<Route
|
||||||
<Route path="/app/tasks" element={<TasksPage />} />
|
path="/app/workflows"
|
||||||
<Route path="/app/admin" element={<AdminPage />} />
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<WorkflowsPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/app/tasks"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<TasksPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/app/admin"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AdminPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<StartPage />} />
|
<Route path="*" element={<StartPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -153,15 +153,11 @@ export const api = {
|
||||||
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`),
|
||||||
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
|
listApprovals: () => req<ApprovalTask[]>("/api/approvals"),
|
||||||
decide: (
|
decide: (taskId: string, decision: "approve" | "reject", note: string) =>
|
||||||
taskId: string,
|
|
||||||
decision: "approve" | "reject",
|
|
||||||
approver: string,
|
|
||||||
note: string,
|
|
||||||
) =>
|
|
||||||
req<Decision>(`/api/approvals/${taskId}/decision`, {
|
req<Decision>(`/api/approvals/${taskId}/decision`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ decision, approver, note }),
|
// Identity comes from the session; the server records the user.
|
||||||
|
body: JSON.stringify({ decision, note }),
|
||||||
}),
|
}),
|
||||||
importFlow: (langflowFlowId: string, name?: string) =>
|
importFlow: (langflowFlowId: string, name?: string) =>
|
||||||
req<{ definition: FlowDefinition; version: FlowVersion }>(
|
req<{ definition: FlowDefinition; version: FlowVersion }>(
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,12 @@ import {
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react";
|
import { IconAlertCircle, IconCheck, IconRefresh, IconX } from "@tabler/icons-react";
|
||||||
import { ApiError, api, type ApprovalTask } from "../api";
|
import { ApiError, api, type ApprovalTask } from "../api";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
import StatusBadge from "./StatusBadge";
|
import StatusBadge from "./StatusBadge";
|
||||||
|
|
||||||
export default function ApprovalsTab() {
|
export default function ApprovalsTab() {
|
||||||
|
const { me } = useAuth();
|
||||||
const [tasks, setTasks] = useState<ApprovalTask[]>([]);
|
const [tasks, setTasks] = useState<ApprovalTask[]>([]);
|
||||||
const [approver, setApprover] = useState("");
|
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busyId, setBusyId] = useState("");
|
const [busyId, setBusyId] = useState("");
|
||||||
|
|
@ -37,7 +38,7 @@ export default function ApprovalsTab() {
|
||||||
setBusyId(taskId);
|
setBusyId(taskId);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await api.decide(taskId, decision, approver, note);
|
const result = await api.decide(taskId, decision, note);
|
||||||
setTasks((prev) =>
|
setTasks((prev) =>
|
||||||
prev.map((t) => (t.id === taskId ? result.task : t)),
|
prev.map((t) => (t.id === taskId ? result.task : t)),
|
||||||
);
|
);
|
||||||
|
|
@ -61,12 +62,11 @@ export default function ApprovalsTab() {
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
|
Angemeldet als {me?.email ?? "–"} — Entscheidungen werden diesem
|
||||||
|
Konto zugeordnet.
|
||||||
|
</Text>
|
||||||
<Group mb="md" align="flex-end">
|
<Group mb="md" align="flex-end">
|
||||||
<TextInput
|
|
||||||
label="Name (Bearbeiter)"
|
|
||||||
value={approver}
|
|
||||||
onChange={(e) => setApprover(e.target.value)}
|
|
||||||
/>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Notiz"
|
label="Notiz"
|
||||||
value={note}
|
value={note}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,9 @@ import {
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconAlertCircle, IconPlayerPlay } from "@tabler/icons-react";
|
import { IconAlertCircle, IconPlayerPlay } from "@tabler/icons-react";
|
||||||
import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api";
|
import { ApiError, api, type FlowDefinition, type InputSchema, type Run } from "../api";
|
||||||
import { useAuth } from "../auth";
|
|
||||||
import StatusBadge from "../components/StatusBadge";
|
import StatusBadge from "../components/StatusBadge";
|
||||||
|
|
||||||
export default function WorkflowsPage() {
|
export default function WorkflowsPage() {
|
||||||
const { me } = useAuth();
|
|
||||||
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
const [flows, setFlows] = useState<FlowDefinition[]>([]);
|
||||||
const [flowId, setFlowId] = useState("");
|
const [flowId, setFlowId] = useState("");
|
||||||
const [schema, setSchema] = useState<InputSchema | null>(null);
|
const [schema, setSchema] = useState<InputSchema | null>(null);
|
||||||
|
|
@ -86,11 +84,6 @@ export default function WorkflowsPage() {
|
||||||
<Title order={2} mb="md">
|
<Title order={2} mb="md">
|
||||||
Workflows
|
Workflows
|
||||||
</Title>
|
</Title>
|
||||||
{!me && (
|
|
||||||
<Alert color="blue" mb="md">
|
|
||||||
Sie sind nicht angemeldet — Läufe nutzen den Standard-Mandanten.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
<Alert color="red" icon={<IconAlertCircle size={18} />} mb="md">
|
||||||
{error}
|
{error}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue