M15: synchronize contracts and acceptance
This commit is contained in:
@@ -2356,3 +2356,45 @@ evidence yet."
|
||||
execution ID visible.
|
||||
- Exact next action: regenerate the checked-in OpenAPI contract, add E2E coverage for the
|
||||
new operator workflows, document credential-safe n8n upgrades and run acceptance.
|
||||
|
||||
## Contract and acceptance synchronization (2026-08-10)
|
||||
|
||||
- Replaced the obsolete hand-maintained OpenAPI baseline with a deterministic snapshot
|
||||
generated directly from the FastAPI application. The committed contract now describes
|
||||
all 53 paths and 75 schemas, including booking lifecycle, operational users, MCP trust
|
||||
headers and n8n execution telemetry.
|
||||
- Added browser acceptance coverage for booking creation/cancellation, user
|
||||
creation/deactivation and vehicle maintenance/release. Existing acceptance journeys
|
||||
now reset their own state, tolerate the configured honest knowledge provider and allow
|
||||
the provider's bounded response window instead of depending on suite order or a demo
|
||||
provider that is not active in production.
|
||||
- Added a credential-reference merge utility and a runbook procedure that preserves live
|
||||
n8n credential IDs during workflow upgrades without exporting or committing secrets.
|
||||
- Hardened logout beyond browser cookie deletion: signed sessions now carry a unique
|
||||
nonce, logout persists a token-hash denylist, expired revocations are pruned, and both
|
||||
demo and operational authentication reject retained or copied cookies server-side.
|
||||
Repeated live refresh/logout coverage passed **40/40**.
|
||||
- Removed a return-form initialization race: the return form now mounts only after the
|
||||
persisted demo manifest is ready, so operator input cannot be overwritten by a late
|
||||
manifest response. The final mobile row interaction is represented by actual link
|
||||
semantics rather than a nested interactive table row.
|
||||
- Split frontend dependency installation from source compilation in the Docker build so
|
||||
dependency layers are cached and reproducible. Upgraded the build toolchain to pinned
|
||||
Vite **8.2.1** and `@vitejs/plugin-react` **6.0.5**; both the production-only and full
|
||||
npm audits report **0 vulnerabilities**.
|
||||
- **Final acceptance evidence**: generated OpenAPI output is byte-for-byte deterministic;
|
||||
frontend TypeScript/production build, ruff, mypy and diff checks pass; Alembic reports
|
||||
`d1f83bc64170 (head)`; the complete backend suite passes **213 tests**; the complete
|
||||
Playwright suite passes **144 tests in 4.8 minutes** against the deployed production
|
||||
bundle.
|
||||
- **Live integration evidence**: a real vehicle return completed through local commit →
|
||||
outbox → the existing central n8n → callback → heartbeat. After the final browser run,
|
||||
Vehicle Return execution **376** is healthy, n8n is operational with zero pending
|
||||
events, MCP Hub is reachable and operational with a real audited
|
||||
`fleet_ops_get_operations_summary` call, and RAGcore reports available/ready.
|
||||
- **Restored hand-off state**: the synthetic reset reports all scenarios ready with 2
|
||||
users, 180 customers, 50 vehicles, 254 bookings, 75 inspections, 40 maintenance
|
||||
records, 33 data-quality issues and 20 workflow runs; `BK-DEMO-RETURN` is active again.
|
||||
- **Exact next action**: none for the locked PoC. All acceptance criteria are satisfied;
|
||||
subsequent work is routine production operation, monitoring and explicitly approved
|
||||
scope beyond this build.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""persist revoked sessions
|
||||
|
||||
Revision ID: d1f83bc64170
|
||||
Revises: b7c7b536df85
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "d1f83bc64170"
|
||||
down_revision = "b7c7b536df85"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"revoked_sessions",
|
||||
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_revoked_sessions_expires_at", "revoked_sessions", ["expires_at"])
|
||||
op.create_index(
|
||||
"ix_revoked_sessions_token_hash",
|
||||
"revoked_sessions",
|
||||
["token_hash"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_revoked_sessions_token_hash", table_name="revoked_sessions")
|
||||
op.drop_index("ix_revoked_sessions_expires_at", table_name="revoked_sessions")
|
||||
op.drop_table("revoked_sessions")
|
||||
@@ -13,6 +13,7 @@ from app.core.db import SessionLocal
|
||||
from app.core.security import SessionPayload, read_session_token
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, Role
|
||||
from app.services.sessions import is_session_revoked
|
||||
|
||||
settings = get_settings()
|
||||
_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined]
|
||||
@@ -29,7 +30,8 @@ def get_db() -> Generator[Session, None, None]:
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload: SessionPayload | None = read_session_token(token) if token else None
|
||||
if payload is None or payload.role not in _VALID_ROLES:
|
||||
revoked = token is not None and is_session_revoked(db, token)
|
||||
if payload is None or payload.role not in _VALID_ROLES or revoked:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
# Demo reset deliberately rebuilds the deterministic users table. Retaining the
|
||||
# signed demo session until the reset endpoint clears its cookie keeps existing demo
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.core.security import (
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, PasswordLoginRequest
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.sessions import revoke_session
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
@@ -37,6 +38,7 @@ def _set_session(response: Response, user: User) -> None:
|
||||
SessionPayload(
|
||||
user_id=str(user.id), public_ref=user.public_ref, role=user.role,
|
||||
display_name=user.display_name, issued_at=int(time.time()),
|
||||
session_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
response.set_cookie(
|
||||
@@ -114,7 +116,8 @@ def get_session(response: Response, user: CurrentUser = Depends(get_current_user
|
||||
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload = read_session_token(token) if token else None
|
||||
if payload is not None:
|
||||
if payload is not None and token is not None:
|
||||
revoke_session(db, token, payload)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
|
||||
from app.services.sessions import revoke_session
|
||||
|
||||
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||
settings = get_settings()
|
||||
@@ -48,6 +49,7 @@ def demo_login(
|
||||
role=user.role,
|
||||
display_name=user.display_name,
|
||||
issued_at=int(time.time()),
|
||||
session_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
response.set_cookie(
|
||||
@@ -85,7 +87,8 @@ def get_session(
|
||||
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload = read_session_token(token) if token else None
|
||||
if payload is not None:
|
||||
if payload is not None and token is not None:
|
||||
revoke_session(db, token, payload)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
|
||||
@@ -20,6 +20,7 @@ class SessionPayload:
|
||||
role: str
|
||||
display_name: str
|
||||
issued_at: int
|
||||
session_id: str = ""
|
||||
|
||||
|
||||
def _sign(data: bytes) -> str:
|
||||
@@ -27,6 +28,11 @@ def _sign(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
|
||||
|
||||
def session_token_hash(token: str) -> str:
|
||||
"""Return a non-reversible identifier safe to persist for token revocation."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_session_token(payload: SessionPayload) -> str:
|
||||
body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
|
||||
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.models.idempotency import IdempotencyRecord
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.models.revoked_session import RevokedSession
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
|
||||
@@ -20,6 +21,7 @@ __all__ = [
|
||||
"Inspection",
|
||||
"MaintenanceRecord",
|
||||
"OutboxEvent",
|
||||
"RevokedSession",
|
||||
"User",
|
||||
"Vehicle",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class RevokedSession(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "revoked_sessions"
|
||||
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
@@ -19,6 +19,7 @@ from app.models.idempotency import IdempotencyRecord
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||
from app.models.revoked_session import RevokedSession
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.services.audit import record_audit_event
|
||||
@@ -92,6 +93,7 @@ _PERSISTENT_TELEMETRY_ACTIONS = (
|
||||
|
||||
def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None:
|
||||
for model in (
|
||||
RevokedSession,
|
||||
OutboxEvent,
|
||||
IdempotencyRecord,
|
||||
DataQualityIssue,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import SessionPayload, session_token_hash
|
||||
from app.models.revoked_session import RevokedSession
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def is_session_revoked(db: Session, token: str) -> bool:
|
||||
token_hash = session_token_hash(token)
|
||||
revoked_id = db.scalar(
|
||||
select(RevokedSession.id).where(RevokedSession.token_hash == token_hash)
|
||||
)
|
||||
return revoked_id is not None
|
||||
|
||||
|
||||
def revoke_session(db: Session, token: str, payload: SessionPayload) -> None:
|
||||
db.execute(delete(RevokedSession).where(RevokedSession.expires_at < datetime.now(UTC)))
|
||||
token_hash = session_token_hash(token)
|
||||
existing = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
|
||||
if existing is not None:
|
||||
return
|
||||
db.add(
|
||||
RevokedSession(
|
||||
token_hash=token_hash,
|
||||
expires_at=datetime.fromtimestamp(
|
||||
payload.issued_at + settings.session_ttl_seconds, UTC
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Generate the checked-in API contract from the FastAPI application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "backend"))
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
|
||||
def main() -> None:
|
||||
schema = app.openapi()
|
||||
schema["info"]["description"] = (
|
||||
"Generated contract for Fleet Ops. The visible product name is Fleet Ops; "
|
||||
"MobilityOps remains the technical repository and service identifier."
|
||||
)
|
||||
schema["servers"] = [{"url": "http://localhost:8128"}]
|
||||
output = ROOT / "contracts" / "openapi.yaml"
|
||||
output.write_text(
|
||||
yaml.safe_dump(schema, sort_keys=False, allow_unicode=True, width=100),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -62,6 +62,34 @@ def test_logout_invalidates_session(ops_client):
|
||||
assert after.status_code == 401
|
||||
|
||||
|
||||
def test_logout_rejects_a_cookie_even_if_the_browser_retains_it(ops_client):
|
||||
from app.core.config import get_settings
|
||||
|
||||
cookie_name = get_settings().session_cookie_name
|
||||
stolen_token = ops_client.cookies.get(cookie_name)
|
||||
assert stolen_token
|
||||
|
||||
logout = ops_client.post("/api/v1/auth/logout")
|
||||
assert logout.status_code == 200
|
||||
|
||||
# Simulate the browser cookie race (or a copied cookie): server-side revocation is
|
||||
# authoritative and must reject the original signed token independently of deletion.
|
||||
ops_client.cookies.set(cookie_name, stolen_token)
|
||||
assert ops_client.get("/api/v1/auth/session").status_code == 401
|
||||
|
||||
# Remove the deliberately injected hostless cookie before exercising a normal browser
|
||||
# login. Otherwise httpx sends it alongside the real testserver cookie, which is not a
|
||||
# state a browser can create for the same origin/path pair.
|
||||
ops_client.cookies.clear()
|
||||
|
||||
# A fresh login in the same second receives a distinct signed token and remains valid.
|
||||
fresh_login = ops_client.post(
|
||||
"/api/v1/demo/login", json={"role": "operations_manager"}
|
||||
)
|
||||
assert fresh_login.status_code == 200
|
||||
assert ops_client.get("/api/v1/auth/session").status_code == 200
|
||||
|
||||
|
||||
def test_logout_without_a_session_is_safe(client):
|
||||
response = client.post("/api/v1/demo/logout")
|
||||
assert response.status_code == 200
|
||||
|
||||
+3955
-216
File diff suppressed because it is too large
Load Diff
@@ -135,6 +135,27 @@ sed -i \
|
||||
docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up -d db api web
|
||||
```
|
||||
|
||||
For an update of already configured workflows, preserve the live credential IDs. n8n
|
||||
2.x's CLI can bind every Header Auth node to the same credential when two credentials of
|
||||
that type exist, even when the committed names differ. Never import the name-only JSON
|
||||
directly over a live workflow. Export first, merge only the non-secret references, then
|
||||
import the generated files:
|
||||
|
||||
```bash
|
||||
docker exec n8n n8n export:workflow --all \
|
||||
--output=/data/backups/mobilityops-pre-update.json
|
||||
python n8n/workflows/merge_credential_refs.py \
|
||||
/mnt/cache/appdata/n8n/backups/mobilityops-pre-update.json \
|
||||
n8n/workflows /mnt/cache/appdata/n8n/imports/mobilityops-safe
|
||||
# import each generated fleet-ops-*.json, publish the four known IDs, then restart n8n
|
||||
```
|
||||
|
||||
The merge utility copies credential IDs and names only; credential values remain inside
|
||||
n8n's encrypted credential store. After restart, export the return workflow and verify
|
||||
that `Return webhook` uses `Fleet Ops Webhook Trigger Token`, while `Record follow-up`
|
||||
and `Report workflow heartbeat` use `Fleet Ops Service Token`. Finally run a real return
|
||||
and verify both a succeeded outbox delivery and a heartbeat execution ID in Automation.
|
||||
|
||||
The setup script reads the callback token from the mode-0600 deployment `.env`, builds and
|
||||
removes a temporary server-side import without writing the token to Git, and restarts the
|
||||
existing n8n so the production webhook is registered. The imported configuration remains
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
RUN npm ci && npm run build
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -88,11 +88,18 @@ test("attention queue row opens the correct record on a mobile viewport tap", as
|
||||
await page.goto("/dashboard");
|
||||
const row = page.locator(".attention-list li.row-clickable").first();
|
||||
await expect(row).toBeVisible();
|
||||
const box = await row.boundingBox();
|
||||
const link = row.locator(".row-link");
|
||||
const [rowBox, linkBox] = await Promise.all([row.boundingBox(), link.boundingBox()]);
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(linkBox).not.toBeNull();
|
||||
// The stretched link itself must cover the whole mobile row. Locator.click handles
|
||||
// scrolling and fixed mobile chrome before clicking the link's empty right-hand area.
|
||||
expect(Math.abs(linkBox!.width - rowBox!.width)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(linkBox!.height - rowBox!.height)).toBeLessThanOrEqual(1);
|
||||
// A real touch context needs `hasTouch`, which this shared spec file doesn't opt into;
|
||||
// a mouse click at the same mobile viewport size still exercises the same CSS layout
|
||||
// and click-target logic, since the app has no touch-specific event handling.
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
|
||||
await link.click({ position: { x: linkBox!.width - 10, y: linkBox!.height / 2 } });
|
||||
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
|
||||
});
|
||||
|
||||
|
||||
@@ -68,10 +68,8 @@ test("knowledge page suggested question returns a grounded, honestly-labelled an
|
||||
// The status badge and retrieval-flow diagram must name the actual active provider
|
||||
// honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is
|
||||
// allowed to mention RAGcore by name when explaining it isn't live yet.
|
||||
await expect(page.locator(".knowledge-status strong")).toHaveText("de demokennisbank");
|
||||
await expect(page.locator(".retrieval-flow")).toContainText("de demokennisbank");
|
||||
await expect(page.locator(".knowledge-status")).not.toContainText("RAGcore");
|
||||
await expect(page.locator(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/);
|
||||
|
||||
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
@@ -74,8 +74,10 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
||||
.getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
|
||||
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
|
||||
await page.getByRole("button", { name: "Vraag stellen" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
||||
await expect(page.getByText("Procedure schadeafhandeling").first()).toBeVisible();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||
const damageSource = page.locator(".source-card", { hasText: /damage-procedure\.md|Procedure schadeafhandeling/ }).first();
|
||||
await expect(damageSource).toBeVisible();
|
||||
await expect(damageSource).toContainText(/zichtbare of gemelde schade/i);
|
||||
});
|
||||
|
||||
await test.step("8. inspect audit entries", async () => {
|
||||
|
||||
@@ -237,17 +237,17 @@ test.describe("knowledge base is grounded in the operator's own language", () =>
|
||||
{
|
||||
lang: "nl-BE",
|
||||
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
|
||||
sourceHint: /schadeafhandeling/i,
|
||||
sourceHint: /zichtbare of gemelde schade/i,
|
||||
},
|
||||
{
|
||||
lang: "en-GB",
|
||||
question: "What should I do when a vehicle returns with damage?",
|
||||
sourceHint: /damage/i,
|
||||
sourceHint: /visible or reported damage/i,
|
||||
},
|
||||
{
|
||||
lang: "fr-BE",
|
||||
question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
|
||||
sourceHint: /dommages/i,
|
||||
sourceHint: /dommages visibles ou signalés/i,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -258,7 +258,9 @@ test.describe("knowledge base is grounded in the operator's own language", () =>
|
||||
await page.goto("/knowledge");
|
||||
await page.locator("#knowledge-question").fill(question);
|
||||
await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click();
|
||||
await expect(page.getByText(sourceHint).first()).toBeVisible({ timeout: 10_000 });
|
||||
const localizedSource = page.locator(".source-card", { hasText: sourceHint }).first();
|
||||
await expect(localizedSource).toBeVisible({ timeout: 15_000 });
|
||||
await expect(localizedSource).toContainText(/damage-procedure\.md|Damage handling|schadeafhandeling|gestion des dommages/i);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -300,12 +302,13 @@ test("automation shows a localized error explanation with the raw error only und
|
||||
await loginAsOpsManager(page, "nl-BE");
|
||||
await page.goto("/automation");
|
||||
|
||||
await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible();
|
||||
const localizedSummary = /Gesimuleerde time-out richting de workflowdienst/;
|
||||
await expect(page.getByText(localizedSummary).first()).toBeVisible();
|
||||
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
|
||||
// Scope to the failed job's own row -- the workflow-evidence table above it also has
|
||||
// "Technische details" toggles (one per workflow), so an unscoped .first() can open
|
||||
// the wrong one.
|
||||
const failedJobRow = page.locator("tr", { has: page.getByText(/tijdelijk niet bereikbaar/) });
|
||||
const failedJobRow = page.locator("tr", { has: page.getByText(localizedSummary) });
|
||||
await failedJobRow.getByText("Technische details").click();
|
||||
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
|
||||
await expect(page).toHaveURL(/\/knowledge$/);
|
||||
await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible();
|
||||
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
});
|
||||
|
||||
@@ -90,7 +90,10 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
|
||||
await page.getByRole("button", { name: /Demo-gids/ }).click();
|
||||
await page.getByRole("button", { name: "Volgende" }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "Gegidste demo" })
|
||||
.getByRole("button", { name: "Volgende", exact: true })
|
||||
.click();
|
||||
});
|
||||
|
||||
await test.step("step 8: review what's real, simulated, or not yet connected", async () => {
|
||||
|
||||
@@ -5,12 +5,20 @@ async function resetDemoData(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/reset");
|
||||
}
|
||||
|
||||
async function switchRole(page: Page) {
|
||||
await page.locator(".operator-menu > summary").click();
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
||||
// app's default for a fresh session, so force English explicitly rather than rewriting
|
||||
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
// Keep reset's Set-Cookie deletion in the standalone request fixture. Sharing the
|
||||
// browser cookie jar here can race that deletion against the UI login below.
|
||||
await resetDemoData(request);
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
@@ -44,7 +52,13 @@ test("vehicles page: status filter and attention-only checkbox both work", async
|
||||
expect(statuses.every((s) => s.includes("maintenance"))).toBeTruthy();
|
||||
|
||||
await page.getByLabel("Status").selectOption("");
|
||||
await page.getByLabel("Attention only").check();
|
||||
await expect(page).not.toHaveURL(/status=maintenance/);
|
||||
await expect(page.getByLabel("Attention only")).not.toBeChecked();
|
||||
// Updating the URL replaces this controlled input. Click it, then assert against the
|
||||
// newly-rendered control rather than asking Playwright to verify the detached node.
|
||||
await page.getByLabel("Attention only").click();
|
||||
await expect(page).toHaveURL(/attention_only=true/);
|
||||
await expect(page.getByLabel("Attention only")).toBeChecked();
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const attentionCells = await page.locator(".data-table tbody tr td:nth-child(6)").allTextContents();
|
||||
expect(attentionCells.every((c) => c.includes("Needs attention"))).toBeTruthy();
|
||||
@@ -158,14 +172,16 @@ test("data quality page: status and rule-type filters work", async ({ page }) =>
|
||||
test("data quality issue detail: defer and reject buttons work", async ({ page, request }) => {
|
||||
await resetDemoData(request);
|
||||
await page.goto("/data-quality?status=open&rule_type=missing_required_field");
|
||||
await page.goto("/data-quality");
|
||||
await page
|
||||
.getByRole("combobox", { name: "Rule type", exact: true })
|
||||
.selectOption("missing_required_field");
|
||||
await expect(page.getByRole("combobox", { name: "Rule type", exact: true })).toHaveValue(
|
||||
"missing_required_field",
|
||||
);
|
||||
const firstLink = page.locator(".data-table tbody tr").first().locator("a");
|
||||
const ref = await firstLink.textContent();
|
||||
await expect(firstLink).toBeVisible();
|
||||
const ref = (await firstLink.textContent())?.trim() ?? "";
|
||||
expect(ref).not.toBe("");
|
||||
await firstLink.click();
|
||||
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
|
||||
await expect(page.getByRole("heading", { name: ref })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Defer" }).click();
|
||||
await expect(page.locator(".badge.status-deferred")).toBeVisible();
|
||||
});
|
||||
@@ -193,8 +209,12 @@ test("data quality: resolving a booking overlap blocks one booking", async ({ pa
|
||||
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
||||
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
|
||||
expect((await booking.json()).status).toBe("blocked");
|
||||
return (await booking.json()).status;
|
||||
})
|
||||
.toBe("blocked");
|
||||
});
|
||||
|
||||
test("data quality: applying the recommended status resolves a vehicle conflict", async ({
|
||||
@@ -308,12 +328,12 @@ test("knowledge page: form submits and clears input", async ({ page }) => {
|
||||
const input = page.getByPlaceholder(/What must I do when a vehicle returns with damage/);
|
||||
await input.fill("What must I do when a vehicle returns with damage?");
|
||||
await page.getByRole("button", { name: "Ask" }).click();
|
||||
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
|
||||
await expect(page.getByText("Grounded in cited procedures")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
test("switch role button logs out and returns to login", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await switchRole(page);
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
@@ -327,7 +347,7 @@ test("session survives a page refresh and restores the correct role", async ({ p
|
||||
|
||||
test("logout invalidates the server session so a refresh returns to login", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await switchRole(page);
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
// Directly re-requesting a protected route after logout must not restore access from a
|
||||
@@ -348,7 +368,7 @@ test("direct navigation to a protected route without a session redirects to logi
|
||||
test("rental employee role has a restricted nav and cannot reach manager-only pages", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await switchRole(page);
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
@@ -400,7 +420,7 @@ test("automation page shows the aggregate n8n integration status, not just the l
|
||||
test("rental employee direct API access to manager-only endpoints is rejected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await switchRole(page);
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
async function reset(request: APIRequestContext) {
|
||||
expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy();
|
||||
expect((await request.post("/api/v1/demo/reset")).ok()).toBeTruthy();
|
||||
expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("operator can create and cancel a booking through the UI", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
await login(page);
|
||||
await page.goto("/bookings/new");
|
||||
await page.getByLabel("Klant zoeken").fill("CUS-0001");
|
||||
const customer = page.locator(".booking-create-form select").nth(0);
|
||||
await expect.poll(() => customer.locator("option").count()).toBeGreaterThan(1);
|
||||
await customer.selectOption({ index: 1 });
|
||||
const vehicle = page.locator(".booking-create-form select").nth(1);
|
||||
await expect.poll(() => vehicle.locator("option").count()).toBeGreaterThan(1);
|
||||
await vehicle.selectOption({ index: 1 });
|
||||
await page.getByRole("button", { name: "Boeking aanmaken" }).click();
|
||||
await expect(page).toHaveURL(/\/bookings\/BK-/);
|
||||
await expect(page.getByText("gereserveerd", { exact: true })).toBeVisible();
|
||||
await page.getByLabel("Reden").fill("Klant annuleert de geplande rit");
|
||||
await page.getByRole("button", { name: "Annulering bevestigen" }).click();
|
||||
await expect(page.getByText("geannuleerd", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("manager can create and deactivate an operational user", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
await login(page);
|
||||
await page.goto("/users");
|
||||
await page.getByLabel("Naam").fill("E2E Planner");
|
||||
await page.getByLabel("E-mail").fill("e2e.planner@example.test");
|
||||
await page.getByLabel("Tijdelijk wachtwoord").fill("secure-e2e-password");
|
||||
await page.getByRole("button", { name: "Gebruiker toevoegen" }).click();
|
||||
const row = page.getByRole("row", { name: /E2E Planner/ });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("button", { name: "Deactiveren" }).click();
|
||||
await expect(row.getByText("inactief", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("manager can record maintenance and release a safe vehicle", async ({ page, request }) => {
|
||||
await reset(request);
|
||||
const vehicles = await (await request.get("/api/v1/vehicles?status=available")).json();
|
||||
let selected = vehicles[0];
|
||||
for (const candidate of vehicles) {
|
||||
const detail = await (await request.get(`/api/v1/vehicles/${candidate.public_ref}`)).json();
|
||||
if (!detail.attention && !detail.bookings.some((booking: { status: string }) => booking.status === "active")) {
|
||||
selected = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
await login(page);
|
||||
await page.goto(`/vehicles/${selected.public_ref}`);
|
||||
await page.getByRole("tab", { name: "Onderhoud" }).click();
|
||||
await page.getByRole("button", { name: "Onderhoud registreren" }).click();
|
||||
await page.getByLabel("Uitgevoerd werk").fill("Periodiek onderhoud en veiligheidscontrole voltooid");
|
||||
await page.getByRole("button", { name: "Onderhoud opslaan" }).click();
|
||||
await expect(page.getByText("onderhoud", { exact: true }).first()).toBeVisible();
|
||||
await page.getByLabel("Reden voor vrijgave").fill("Veiligheidscontrole afgerond zonder open punten");
|
||||
await page.getByRole("button", { name: "Voertuig vrijgeven" }).click();
|
||||
await expect(page.getByText("beschikbaar", { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
@@ -1,8 +1,20 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
||||
|
||||
async function reset(request: APIRequestContext) {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
await request.post("/api/v1/demo/reset");
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
}
|
||||
|
||||
async function login(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ request }) => reset(request));
|
||||
|
||||
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
|
||||
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
|
||||
|
||||
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
|
||||
expect(vehicles.ok()).toBeTruthy();
|
||||
const vehiclePage = await vehicles.json();
|
||||
@@ -14,6 +26,7 @@ test("operational lists use bounded server pages and retain filter state in the
|
||||
const auditPage = await audit.json();
|
||||
expect(auditPage.items.length).toBeLessThanOrEqual(25);
|
||||
|
||||
await login(page);
|
||||
await page.goto("/vehicles?status=maintenance&page=1");
|
||||
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
|
||||
await expect(page).toHaveURL(/status=maintenance/);
|
||||
@@ -21,8 +34,7 @@ test("operational lists use bounded server pages and retain filter state in the
|
||||
|
||||
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
|
||||
await login(page);
|
||||
await page.goto("/bookings/BK-DEMO-RETURN");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
|
||||
|
||||
@@ -25,9 +25,11 @@ test("control-centre shell exposes landmarks, persisted readiness and active nav
|
||||
});
|
||||
|
||||
test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => {
|
||||
await page.keyboard.press("Control+k");
|
||||
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
|
||||
await expect(async () => {
|
||||
await page.keyboard.press("Control+k");
|
||||
await expect(search).toBeFocused();
|
||||
}).toPass({ timeout: 5_000 });
|
||||
await search.fill("MO-024");
|
||||
const result = page.getByRole("option", { name: /MO-024/ });
|
||||
await expect(result).toBeVisible();
|
||||
|
||||
Generated
+511
-1350
File diff suppressed because it is too large
Load Diff
@@ -21,8 +21,8 @@
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@vitejs/plugin-react": "6.0.5",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "5.4.21"
|
||||
"vite": "8.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export const KNOWN_CODES = new Set([
|
||||
"NO_CONFLICT_DETECTED",
|
||||
"RECOMMENDATION_STALE",
|
||||
"UNAUTHORIZED_SERVICE",
|
||||
"UNKNOWN_WORKFLOW",
|
||||
]);
|
||||
|
||||
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
|
||||
|
||||
@@ -316,6 +316,7 @@ export function Layout() {
|
||||
<div className="topbar-meta">
|
||||
{demoMode && <DemoGuideTrigger />}
|
||||
{demoMode && <DemoBadge />}
|
||||
{user && <LanguageSwitcher compact />}
|
||||
{user && (
|
||||
<details className="operator-menu">
|
||||
<summary className="operator">
|
||||
@@ -323,7 +324,6 @@ export function Layout() {
|
||||
<span><strong>{user.display_name}</strong><small>{user.role === "operations_manager" ? t("auth:roleOperationsManager") : t("auth:roleRentalEmployee")}</small></span>
|
||||
</summary>
|
||||
<div className="operator-popover">
|
||||
<LanguageSwitcher compact />
|
||||
<span className="timezone"><Icon name="clock" /> {t("common:timezone")}</span>
|
||||
<button className="operator-logout" type="button" onClick={handleLogout}>
|
||||
<Icon name="logout" /> {t("switchRole")}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { api, onUnauthorized } from "../api/client";
|
||||
import { api, ApiError, onUnauthorized } from "../api/client";
|
||||
import type { CurrentUser, Role, SystemStatus } from "../api/types";
|
||||
|
||||
interface AuthState {
|
||||
@@ -93,13 +93,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
setUser(null);
|
||||
cacheUser(null);
|
||||
try {
|
||||
// A logout response expires an HttpOnly cookie. Chromium can expose a very small
|
||||
// race between resolving fetch() and applying that Set-Cookie header to an
|
||||
// immediate top-level navigation. Confirm the server now rejects the session
|
||||
// before allowing the router to continue; retrying logout is idempotent.
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await api.post("/api/v1/auth/logout");
|
||||
try {
|
||||
await api.get<CurrentUser>("/api/v1/auth/session");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best effort: the cookie is cleared server-side when it works, and the client has
|
||||
// already dropped its own state either way.
|
||||
// still drops its own state when the request itself is unavailable.
|
||||
} finally {
|
||||
// Keep RequireAuth from redirecting to /login while the server-side invalidation
|
||||
// is still in flight. Otherwise a very fast navigation can race ahead of logout.
|
||||
setUser(null);
|
||||
cacheUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -142,6 +142,10 @@
|
||||
"UNAUTHORIZED_SERVICE": {
|
||||
"title": "Service authorisation failed",
|
||||
"explanation": "This automated request could not be authorised."
|
||||
},
|
||||
"UNKNOWN_WORKFLOW": {
|
||||
"title": "Unknown automation workflow",
|
||||
"explanation": "This workflow is not one of the four managed automation workflows."
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"quality": "Data quality",
|
||||
"knowledge": "Knowledge",
|
||||
"integrations": "Integrations",
|
||||
"audit": "Audit trail"
|
||||
"audit": "Audit trail",
|
||||
"users": "Users"
|
||||
},
|
||||
"primaryNavLabel": "Primary navigation",
|
||||
"mobileNavLabel": "Mobile navigation",
|
||||
@@ -41,8 +42,7 @@
|
||||
"quality": "Quality workbench",
|
||||
"knowledge": "Procedure assistant",
|
||||
"integrations": "Automation and integration status",
|
||||
"audit": "Audit history",
|
||||
"users": "Users"
|
||||
"audit": "Audit history"
|
||||
},
|
||||
"switchRole": "Switch role",
|
||||
"switchRoleTitle": "Switch demo role",
|
||||
|
||||
@@ -142,6 +142,10 @@
|
||||
"UNAUTHORIZED_SERVICE": {
|
||||
"title": "Échec de l'autorisation du service",
|
||||
"explanation": "Cette demande automatisée n'a pas pu être autorisée."
|
||||
},
|
||||
"UNKNOWN_WORKFLOW": {
|
||||
"title": "Workflow d’automatisation inconnu",
|
||||
"explanation": "Ce workflow ne fait pas partie des quatre automatisations gérées."
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"quality": "Qualité des données",
|
||||
"knowledge": "Connaissances",
|
||||
"integrations": "Intégrations",
|
||||
"audit": "Piste d'audit"
|
||||
"audit": "Piste d'audit",
|
||||
"users": "Utilisateurs"
|
||||
},
|
||||
"primaryNavLabel": "Navigation principale",
|
||||
"mobileNavLabel": "Navigation mobile",
|
||||
@@ -41,8 +42,7 @@
|
||||
"quality": "Atelier qualité",
|
||||
"knowledge": "Assistant de procédures",
|
||||
"integrations": "Statut d'automatisation et d'intégration",
|
||||
"audit": "Historique d’audit",
|
||||
"users": "Utilisateurs"
|
||||
"audit": "Historique d’audit"
|
||||
},
|
||||
"switchRole": "Changer de rôle",
|
||||
"switchRoleTitle": "Changer de rôle de démo",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
|
||||
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Employé de location" }
|
||||
"roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
|
||||
}
|
||||
|
||||
@@ -142,6 +142,10 @@
|
||||
"UNAUTHORIZED_SERVICE": {
|
||||
"title": "Dienstautorisatie mislukt",
|
||||
"explanation": "Deze geautomatiseerde aanvraag kon niet geautoriseerd worden."
|
||||
},
|
||||
"UNKNOWN_WORKFLOW": {
|
||||
"title": "Onbekende automatiseringsworkflow",
|
||||
"explanation": "Deze workflow hoort niet bij de vier beheerde automatiseringsflows."
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
|
||||
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Verhuurmedewerker" }
|
||||
"roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function BookingDetail() {
|
||||
const { t } = useTranslation(["bookings", "returns", "errors"]);
|
||||
const { formatDateTime, formatNumber } = useLocaleFormat();
|
||||
const { publicRef } = useParams<{ publicRef: string }>();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { manifest, loading: manifestLoading } = useDemoManifest();
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [returnResult, setReturnResult] = useState<RegisterReturnResult | null>(null);
|
||||
@@ -126,10 +126,10 @@ export function BookingDetail() {
|
||||
this is the known demo scenario, so ReturnForm's suggestedOdometerKm is correct
|
||||
from its very first render -- never updated asynchronously after mount, which
|
||||
previously raced with anyone already typing into the field. */}
|
||||
{!returnResult && booking.status === "active" && isReturnAnomalyScenario && canonicalOdometerKm === null && (
|
||||
{!returnResult && booking.status === "active" && (manifestLoading || (isReturnAnomalyScenario && canonicalOdometerKm === null)) && (
|
||||
<LoadingState label={t("returns:scenario.preparing")} />
|
||||
)}
|
||||
{!returnResult && booking.status === "active" && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
|
||||
{!returnResult && booking.status === "active" && !manifestLoading && (!isReturnAnomalyScenario || canonicalOdometerKm !== null) && (
|
||||
<ReturnForm
|
||||
bookingRef={booking.public_ref}
|
||||
onRegistered={handleRegistered}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge instance-specific n8n credential IDs into versioned workflow definitions.
|
||||
|
||||
n8n's CLI does not reliably resolve two credentials of the same type by name during an
|
||||
update. This utility copies only credential reference IDs/names from a pre-update export;
|
||||
it never reads or writes credential values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SERVICE_NODE_CANDIDATES = {
|
||||
"mobilityops-return-processing": ("Record follow-up",),
|
||||
"mobilityops-scheduled-quality-scan": ("Run quality scan",),
|
||||
"6wbkc4d1AouGpmWT": ("Report sync result to Fleet Ops",),
|
||||
"Xppn2rAEqUuyiCJF": ("Report failure to Fleet Ops", "failure to Fleet Ops"),
|
||||
}
|
||||
|
||||
|
||||
def _workflow_list(path: Path) -> list[dict[str, Any]]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
return payload if isinstance(payload, list) else [payload]
|
||||
|
||||
|
||||
def merge(backup_path: Path, source_dir: Path, output_dir: Path) -> list[Path]:
|
||||
backups = {workflow["id"]: workflow for workflow in _workflow_list(backup_path)}
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
written: list[Path] = []
|
||||
for source_path in sorted(source_dir.glob("fleet-ops-*.json")):
|
||||
updated = _workflow_list(source_path)[0]
|
||||
workflow_id = updated["id"]
|
||||
if workflow_id not in SERVICE_NODE_CANDIDATES or workflow_id not in backups:
|
||||
continue
|
||||
original_nodes = {node["name"]: node for node in backups[workflow_id]["nodes"]}
|
||||
service_node = next(
|
||||
(
|
||||
original_nodes[name]
|
||||
for name in SERVICE_NODE_CANDIDATES[workflow_id]
|
||||
if name in original_nodes
|
||||
),
|
||||
None,
|
||||
)
|
||||
if service_node is None or not service_node.get("credentials"):
|
||||
raise ValueError(f"No service credential reference found for {workflow_id}")
|
||||
service_credentials = service_node["credentials"]
|
||||
for node in updated["nodes"]:
|
||||
original = original_nodes.get(node["name"])
|
||||
if original and original.get("credentials"):
|
||||
node["credentials"] = copy.deepcopy(original["credentials"])
|
||||
elif node.get("credentials") or node["name"] == "Report workflow heartbeat":
|
||||
node["credentials"] = copy.deepcopy(service_credentials)
|
||||
target = output_dir / source_path.name
|
||||
target.write_text(
|
||||
json.dumps(updated, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
written.append(target)
|
||||
return written
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("backup", type=Path, help="n8n export:workflow --all JSON file")
|
||||
parser.add_argument("source_dir", type=Path, help="versioned workflow directory")
|
||||
parser.add_argument("output_dir", type=Path, help="safe import output directory")
|
||||
args = parser.parse_args()
|
||||
for path in merge(args.backup, args.source_dir, args.output_dir):
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user