From 07b4ccbda5e89cbf4a16bd07cd9b8a5730204f30 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 28 Oct 2026 14:59:43 +0100 Subject: [PATCH 1/3] portal: Workflow-Aufruf per Bearer (X-Workflow-Token), X-User-Id nur noch als Uebergang Co-Authored-By: Claude Sonnet 5.5 --- web/portal/app/ModuleGrid.render.test.tsx | 1 + web/portal/app/ModuleGrid.tsx | 9 ++++---- web/portal/app/api/worklist/route.test.ts | 28 +++++++++++++++++++---- web/portal/app/api/worklist/route.ts | 17 +++++++++++--- web/portal/lib/auth.ts | 10 ++++++++ web/portal/lib/http.ts | 13 +++++++++++ web/portal/lib/worklist.test.ts | 11 +++++++++ web/portal/lib/worklist.ts | 7 ++++-- 8 files changed, 83 insertions(+), 13 deletions(-) diff --git a/web/portal/app/ModuleGrid.render.test.tsx b/web/portal/app/ModuleGrid.render.test.tsx index 26cb5f51..007c9a1b 100644 --- a/web/portal/app/ModuleGrid.render.test.tsx +++ b/web/portal/app/ModuleGrid.render.test.tsx @@ -13,6 +13,7 @@ vi.mock("../lib/http", () => ({ identity: () => ({ userId: "u-1", tenant: "acme", role: "user" }), fetch: (...a: unknown[]) => fetchMock(...a), }), + getWorkflowSession: () => ({ token: () => Promise.reject(new Error("kein workflow-token")) }), })); import { ModuleGrid } from "./ModuleGrid"; diff --git a/web/portal/app/ModuleGrid.tsx b/web/portal/app/ModuleGrid.tsx index 749b40e8..e323e266 100644 --- a/web/portal/app/ModuleGrid.tsx +++ b/web/portal/app/ModuleGrid.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useI18n, resolveModuleHref } from "@nexarch/shl"; import type { ModuleEntry } from "../lib/modules"; import type { Worklist } from "../lib/worklist"; -import { getSession } from "../lib/http"; +import { getSession, getWorkflowSession } from "../lib/http"; import { ModuleTile, StatTile } from "./Tile"; import { Skeleton } from "./Skeleton"; import { CommandPalette } from "./CommandPalette"; @@ -23,9 +23,9 @@ export async function fetchVisibleModules(fetchImpl: typeof fetch): Promise { +export async function fetchWorklist(fetchImpl: typeof fetch, workflowToken?: string): Promise { try { - const res = await fetchImpl("/api/worklist"); + const res = await fetchImpl("/api/worklist", workflowToken ? { headers: { "X-Workflow-Token": workflowToken } } : undefined); return res.ok ? ((await res.json()) as Worklist) : null; } catch { return null; @@ -87,7 +87,8 @@ export function ModuleGrid() { return; } const call = (input: RequestInfo | URL, init?: RequestInit) => session.fetch(input, init); - const [modules, worklist] = await Promise.all([fetchVisibleModules(call), fetchWorklist(call)]); + const workflowToken = await getWorkflowSession().token().catch(() => undefined); + const [modules, worklist] = await Promise.all([fetchVisibleModules(call), fetchWorklist(call, workflowToken)]); if (!cancelled) setState({ status: "ready", modules, worklist, who: { userId: identity.userId, tenant: identity.tenant } }); } catch { if (!cancelled) setState({ status: "error" }); diff --git a/web/portal/app/api/worklist/route.test.ts b/web/portal/app/api/worklist/route.test.ts index bb7db597..adefcbf5 100644 --- a/web/portal/app/api/worklist/route.test.ts +++ b/web/portal/app/api/worklist/route.test.ts @@ -15,10 +15,12 @@ function mint(claims: Record): string { } describe("GET /api/worklist", () => { - let wfCalls: { url: string; user: string }[]; + let wfCalls: { url: string; user: string; auth?: string }[]; - const req = (token?: string, query = "") => - new NextRequest(`http://portal.example/api/worklist${query}`, { headers: token ? { authorization: `Bearer ${token}` } : {} }); + const req = (token?: string, query = "", wfToken?: string) => + new NextRequest(`http://portal.example/api/worklist${query}`, { + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(wfToken ? { "x-workflow-token": wfToken } : {}) }, + }); beforeEach(async () => { process.env.PORTAL_WORKFLOW_API_URL = "http://wf.example"; @@ -31,7 +33,8 @@ describe("GET /api/worklist", () => { if (u.endsWith("/auth/module-jwks")) { return new Response(JSON.stringify({ keys: [{ kid: "k1", public_key: rawPub.toString("base64") }] }), { status: 200 }); } - wfCalls.push({ url: u, user: (init?.headers as Record)["X-User-Id"] }); + const h = init?.headers as Record; + wfCalls.push({ url: u, user: h["X-User-Id"], auth: h.Authorization } as never); return new Response(JSON.stringify([]), { status: 200 }); }) as typeof fetch; }); @@ -57,6 +60,23 @@ describe("GET /api/worklist", () => { expect(wfCalls.every((c) => c.user === "u1")).toBe(true); }); + it("reicht ein gueltiges Workflow-Token als Bearer weiter und sendet keine X-User-Id", async () => { + const { GET } = await import("./route"); + const wf = mint({ sub: "u1", tenant: "acme", aud: "workflow" }); + const res = await GET(req(mint({ sub: "u1", tenant: "acme", aud: "portal" }), "", wf)); + expect(res.status).toBe(200); + expect(wfCalls.length).toBe(3); + expect(wfCalls.every((c) => c.auth === `Bearer ${wf}` && c.user === undefined)).toBe(true); + }); + + it("lehnt Workflow-Token eines anderen Benutzers oder falschen Zielmoduls ab", async () => { + const { GET } = await import("./route"); + const portal = mint({ sub: "u1", tenant: "acme", aud: "portal" }); + expect((await GET(req(portal, "", mint({ sub: "u2", tenant: "acme", aud: "workflow" })))).status).toBe(401); + expect((await GET(req(portal, "", mint({ sub: "u1", tenant: "acme", aud: "portal" })))).status).toBe(401); + expect(wfCalls.length).toBe(0); + }); + it("cached je Mandant und Benutzer", async () => { const { GET } = await import("./route"); await GET(req(mint({ sub: "u1", tenant: "acme" }))); diff --git a/web/portal/app/api/worklist/route.ts b/web/portal/app/api/worklist/route.ts index 97920865..b4adbdfb 100644 --- a/web/portal/app/api/worklist/route.ts +++ b/web/portal/app/api/worklist/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { authenticate, coreConfigured } from "../../../lib/auth"; +import { authenticate, coreConfigured, verifyWorkflowToken } from "../../../lib/auth"; import { worklistCache } from "../../../lib/caches"; import { loadWorklist } from "../../../lib/worklist"; @@ -7,7 +7,8 @@ export const dynamic = "force-dynamic"; const CACHE_TTL_MS = Number(process.env.PORTAL_WORKLIST_CACHE_SECONDS ?? "15") * 1000; // Nur Zaehler, keine Inhalte. Benutzer und Mandant stammen aus dem verifizierten Token; -// die Workflow-API kennt bisher nur X-User-Id (Uebergangsmodus), der Header wird hier serverseitig gesetzt. +// mit X-Workflow-Token (Zielmodul workflow, gleicher Benutzer und Mandant) geht der Aufruf per Bearer an die Workflow-API, +// ohne ihn als X-User-Id (Uebergangsmodus). export async function GET(request: NextRequest) { const base = process.env.PORTAL_WORKFLOW_API_URL; if (!base || !coreConfigured()) { @@ -16,12 +17,22 @@ export async function GET(request: NextRequest) { const claims = await authenticate(request); if (!claims) return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 }); + const wfHeader = request.headers.get("x-workflow-token"); + let workflowToken: string | undefined; + if (wfHeader) { + const wfClaims = await verifyWorkflowToken(wfHeader); + if (!wfClaims || wfClaims.sub !== claims.sub || wfClaims.tenant !== claims.tenant) { + return NextResponse.json({ error: "Anmeldung erforderlich" }, { status: 401 }); + } + workflowToken = wfHeader; + } + const noStore = { "Cache-Control": "no-store" }; const key = `${claims.tenant}\u0000${claims.sub}`; const hit = worklistCache.get(key, CACHE_TTL_MS); if (hit) return NextResponse.json(hit, { headers: noStore }); - const data = await loadWorklist(base, claims.tenant, claims.sub); + const data = await loadWorklist(base, claims.tenant, claims.sub, fetch, workflowToken); worklistCache.set(key, data); return NextResponse.json(data, { headers: noStore }); } diff --git a/web/portal/lib/auth.ts b/web/portal/lib/auth.ts index 0d379b8e..f9e57b36 100644 --- a/web/portal/lib/auth.ts +++ b/web/portal/lib/auth.ts @@ -17,3 +17,13 @@ export async function authenticate(request: NextRequest): Promise { + const coreBase = process.env.PORTAL_CORE_API_URL ?? process.env.NEXT_PUBLIC_CORE_API_BASE; + if (!coreBase) return null; + return verifyModuleToken(token, { + jwksUrl: `${coreBase.replace(/\/$/, "")}/auth/module-jwks`, + issuer: process.env.PORTAL_TOKEN_ISSUER, + audience: process.env.PORTAL_WORKFLOW_TOKEN_AUDIENCE || "workflow", + }); +} diff --git a/web/portal/lib/http.ts b/web/portal/lib/http.ts index 255a8c5a..43f959a3 100644 --- a/web/portal/lib/http.ts +++ b/web/portal/lib/http.ts @@ -1,6 +1,7 @@ import { createSession, redirectToLogin, type Session } from "@nexarch/shl"; let session: Session | undefined; +let workflowSession: Session | undefined; export function getSession(): Session { if (!session) { @@ -17,6 +18,18 @@ export function getSession(): Session { return session; } +export function getWorkflowSession(): Session { + if (!workflowSession) { + const coreBase = process.env.NEXT_PUBLIC_CORE_API_BASE; + if (!coreBase) { + throw new Error("NEXT_PUBLIC_CORE_API_BASE ist nicht gesetzt (Umgebungsvariable erforderlich)"); + } + workflowSession = createSession({ coreBase, module: "workflow" }); + } + return workflowSession; +} + export function resetSessionForTests(): void { session = undefined; + workflowSession = undefined; } diff --git a/web/portal/lib/worklist.test.ts b/web/portal/lib/worklist.test.ts index 62d1ca70..d7099389 100644 --- a/web/portal/lib/worklist.test.ts +++ b/web/portal/lib/worklist.test.ts @@ -46,6 +46,17 @@ describe("loadWorklist", () => { expect(calls.every((c) => c.headers["X-User-Id"] === "u1" && c.headers["X-Tenant-Slug"] === "acme")).toBe(true); }); + it("sendet mit Workflow-Token nur Authorization und keine X-User-Id", async () => { + const seen: Record[] = []; + const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { + seen.push(init.headers as Record); + return new Response(JSON.stringify([]), { status: 200 }); + }) as unknown as typeof fetch; + await loadWorklist("http://wf.example", "acme", "u1", fetchImpl, "tok"); + expect(seen).toHaveLength(3); + expect(seen.every((h) => h.Authorization === "Bearer tok" && !("X-User-Id" in h) && !("X-Tenant-Slug" in h))).toBe(true); + }); + it("markiert ausgefallene Quellen mit null, ohne die anderen zu verlieren", async () => { const fetchImpl = vi.fn(async (url: string) => { if (url.endsWith("/tasks")) throw new Error("down"); diff --git a/web/portal/lib/worklist.ts b/web/portal/lib/worklist.ts index d9c4ad7a..e53485f5 100644 --- a/web/portal/lib/worklist.ts +++ b/web/portal/lib/worklist.ts @@ -39,10 +39,13 @@ export async function loadWorklist( base: string, tenant: string, userId: string, - fetchImpl: typeof fetch = fetch + fetchImpl: typeof fetch = fetch, + workflowToken?: string ): Promise { const root = base.replace(/\/$/, ""); - const headers = { "X-User-Id": userId, "X-Tenant-Slug": tenant, Accept: "application/json" }; + const headers: Record = workflowToken + ? { Authorization: `Bearer ${workflowToken}`, Accept: "application/json" } + : { "X-User-Id": userId, "X-Tenant-Slug": tenant, Accept: "application/json" }; const get = async (path: string): Promise => { try { const res = await fetchImpl(`${root}${path}`, { headers, cache: "no-store" }); -- 2.47.3 From 0032aa7d81c0cff53cbf7e051bd849c0d7693252 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 28 Oct 2026 15:02:14 +0100 Subject: [PATCH 2/3] workflow-approvals, workflow-tasks: Sitzung und Bearer statt X-User-Id aus der URL Mit NEXT_PUBLIC_CORE_API_BASE holen die Frontends ein workflow-Token ueber die Sitzung und senden Authorization; die BFF-Routen reichen es weiter und ignorieren userId/tenant aus der URL. Ohne die Variable bleibt der Uebergangsweg. Co-Authored-By: Claude Sonnet 5.5 --- .../app/api/approvals/[id]/decide/route.ts | 6 ++-- .../app/api/approvals/route.test.ts | 16 ++++++++++ .../app/api/approvals/route.ts | 6 ++-- web/workflow-approvals/app/page.tsx | 9 ++++-- web/workflow-approvals/lib/api.ts | 6 ++-- web/workflow-approvals/lib/forward.ts | 6 ++++ web/workflow-approvals/lib/http.ts | 29 +++++++++++++++++++ .../app/api/tasks/[id]/status/route.ts | 6 ++-- .../app/api/tasks/route.test.ts | 12 ++++++++ web/workflow-tasks/app/api/tasks/route.ts | 6 ++-- web/workflow-tasks/app/page.tsx | 13 +++++++-- web/workflow-tasks/lib/api.ts | 6 ++-- web/workflow-tasks/lib/forward.ts | 6 ++++ web/workflow-tasks/lib/http.ts | 29 +++++++++++++++++++ 14 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 web/workflow-approvals/lib/forward.ts create mode 100644 web/workflow-approvals/lib/http.ts create mode 100644 web/workflow-tasks/lib/forward.ts create mode 100644 web/workflow-tasks/lib/http.ts diff --git a/web/workflow-approvals/app/api/approvals/[id]/decide/route.ts b/web/workflow-approvals/app/api/approvals/[id]/decide/route.ts index e298fc2d..ea7d23d3 100644 --- a/web/workflow-approvals/app/api/approvals/[id]/decide/route.ts +++ b/web/workflow-approvals/app/api/approvals/[id]/decide/route.ts @@ -3,6 +3,7 @@ // unveraendert durch, damit die Oberflaeche 409 (bereits entschieden) und // 403 (nicht zustaendig) korrekt unterscheiden kann. import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../../../lib/forward"; function backendURL(): string { const base = process.env.WORKFLOW_APPROVALS_API_URL; @@ -14,8 +15,9 @@ function backendURL(): string { export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } @@ -28,7 +30,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ try { backendResponse = await fetch(`${backendURL()}/approvals/${encodeURIComponent(id)}/decide`, { method: "POST", - headers: { "Content-Type": "application/json", "X-User-Id": userId }, + headers: { "Content-Type": "application/json", ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }) }, body: JSON.stringify({ decision: payload.decision, reason: payload.reason ?? "" }), }); } catch (err) { diff --git a/web/workflow-approvals/app/api/approvals/route.test.ts b/web/workflow-approvals/app/api/approvals/route.test.ts index 01050ba0..a0707e9b 100644 --- a/web/workflow-approvals/app/api/approvals/route.test.ts +++ b/web/workflow-approvals/app/api/approvals/route.test.ts @@ -28,4 +28,20 @@ describe("GET /api/approvals", () => { expect(res.status).toBe(200); expect(seenHeader).toBe("alice"); }); + + it("leitet Bearer weiter und ignoriert userId aus der URL", async () => { + process.env.WORKFLOW_APPROVALS_API_URL = "http://backend.local"; + let seen: Record = {}; + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: RequestInit) => { + seen = init?.headers as Record; + return Promise.resolve(new Response(JSON.stringify([]), { status: 200 })); + }) + ); + const res = await GET(new NextRequest("http://localhost/api/approvals?userId=fremd", { headers: { authorization: "Bearer tok" } })); + expect(res.status).toBe(200); + expect(seen.Authorization).toBe("Bearer tok"); + expect(seen["X-User-Id"]).toBeUndefined(); + }); }); diff --git a/web/workflow-approvals/app/api/approvals/route.ts b/web/workflow-approvals/app/api/approvals/route.ts index b452a053..9ffe98af 100644 --- a/web/workflow-approvals/app/api/approvals/route.ts +++ b/web/workflow-approvals/app/api/approvals/route.ts @@ -4,6 +4,7 @@ // Session-Anbindung (auesserhalb dieser Kachel) gibt der Aufrufer sie // als Query-Parameter mit, wie tenantSlug in web/mail-search. import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../lib/forward"; function backendURL(): string { const base = process.env.WORKFLOW_APPROVALS_API_URL; @@ -14,15 +15,16 @@ function backendURL(): string { } export async function GET(request: NextRequest) { + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } let backendResponse: Response; try { backendResponse = await fetch(`${backendURL()}/approvals`, { - headers: { "X-User-Id": userId }, + headers: bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }, cache: "no-store", }); } catch (err) { diff --git a/web/workflow-approvals/app/page.tsx b/web/workflow-approvals/app/page.tsx index 28acbeaf..88b227c5 100644 --- a/web/workflow-approvals/app/page.tsx +++ b/web/workflow-approvals/app/page.tsx @@ -1,13 +1,18 @@ "use client"; -import { Suspense } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { TextField } from "@nexarch/shl"; import { ApprovalList } from "./ApprovalList"; +import { bearerEnabled, sessionUserId } from "../lib/http"; function ApprovalsPage() { const params = useSearchParams(); - const userId = params.get("user") ?? ""; + const [sessionUser, setSessionUser] = useState(""); + useEffect(() => { + sessionUserId().then(setSessionUser).catch(() => {}); + }, []); + const userId = bearerEnabled() ? sessionUser : (params.get("user") ?? ""); return (
diff --git a/web/workflow-approvals/lib/api.ts b/web/workflow-approvals/lib/api.ts index aedc6f72..1eebc34e 100644 --- a/web/workflow-approvals/lib/api.ts +++ b/web/workflow-approvals/lib/api.ts @@ -3,6 +3,8 @@ // Client aus (Rolle: "Alle Daten ausschliesslich ueber die oeffentliche // Schnittstelle beziehen"). +import { apiFetch } from "./http"; + export class ApiError extends Error { status: number; constructor(message: string, status: number) { @@ -22,7 +24,7 @@ export interface ApprovalTask { } export async function listApprovals(userId: string): Promise { - const res = await fetch(`/api/approvals?userId=${encodeURIComponent(userId)}`, { cache: "no-store" }); + const res = await apiFetch(`/api/approvals?userId=${encodeURIComponent(userId)}`, { cache: "no-store" }); const body = await res.json().catch(() => null); if (!res.ok) { throw new ApiError(body?.error ?? `Freigaben konnten nicht geladen werden (${res.status})`, res.status); @@ -38,7 +40,7 @@ export async function decideApproval( decision: Decision, reason: string ): Promise { - const res = await fetch(`/api/approvals/${encodeURIComponent(id)}/decide?userId=${encodeURIComponent(userId)}`, { + const res = await apiFetch(`/api/approvals/${encodeURIComponent(id)}/decide?userId=${encodeURIComponent(userId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision, reason }), diff --git a/web/workflow-approvals/lib/forward.ts b/web/workflow-approvals/lib/forward.ts new file mode 100644 index 00000000..1a128bf9 --- /dev/null +++ b/web/workflow-approvals/lib/forward.ts @@ -0,0 +1,6 @@ +import type { NextRequest } from "next/server"; + +export function bearerHeader(request: NextRequest): string | null { + const auth = request.headers.get("authorization") ?? ""; + return auth.startsWith("Bearer ") && auth.length > 7 ? auth : null; +} diff --git a/web/workflow-approvals/lib/http.ts b/web/workflow-approvals/lib/http.ts new file mode 100644 index 00000000..46286a65 --- /dev/null +++ b/web/workflow-approvals/lib/http.ts @@ -0,0 +1,29 @@ +import { createSession, redirectToLogin, type Session } from "@nexarch/shl"; + +let session: Session | undefined; + +export function bearerEnabled(): boolean { + return Boolean(process.env.NEXT_PUBLIC_CORE_API_BASE); +} + +export function getSession(): Session { + if (!session) { + session = createSession({ + coreBase: process.env.NEXT_PUBLIC_CORE_API_BASE as string, + module: "workflow", + onLoginRequired: () => redirectToLogin(process.env.NEXT_PUBLIC_LOGIN_URL), + }); + } + return session; +} + +export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return bearerEnabled() ? getSession().fetch(input, init) : fetch(input, init); +} + +export async function sessionUserId(): Promise { + if (!bearerEnabled()) return ""; + const s = getSession(); + await s.token(); + return s.identity()?.userId ?? ""; +} diff --git a/web/workflow-tasks/app/api/tasks/[id]/status/route.ts b/web/workflow-tasks/app/api/tasks/[id]/status/route.ts index f77f4b73..a93514c4 100644 --- a/web/workflow-tasks/app/api/tasks/[id]/status/route.ts +++ b/web/workflow-tasks/app/api/tasks/[id]/status/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../../../lib/forward"; function backendURL(): string { const base = process.env.WORKFLOW_TASKS_API_URL; @@ -10,9 +11,10 @@ function backendURL(): string { export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); const tenantSlug = request.nextUrl.searchParams.get("tenant"); - if (!userId || !tenantSlug) { + if (!bearer && (!userId || !tenantSlug)) { return NextResponse.json({ error: "'userId' und 'tenant' sind erforderlich" }, { status: 400 }); } const payload = await request.json().catch(() => null); @@ -24,7 +26,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< try { backendResponse = await fetch(`${backendURL()}/tasks/${encodeURIComponent(id)}/status`, { method: "PATCH", - headers: { "Content-Type": "application/json", "X-User-Id": userId, "X-Tenant-Slug": tenantSlug }, + headers: { "Content-Type": "application/json", ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string, "X-Tenant-Slug": tenantSlug as string }) }, body: JSON.stringify(payload), }); } catch (err) { diff --git a/web/workflow-tasks/app/api/tasks/route.test.ts b/web/workflow-tasks/app/api/tasks/route.test.ts index 7699c184..5ffb1e40 100644 --- a/web/workflow-tasks/app/api/tasks/route.test.ts +++ b/web/workflow-tasks/app/api/tasks/route.test.ts @@ -41,4 +41,16 @@ describe("GET /api/tasks", () => { const res = await GET(req); expect(res.status).toBe(403); }); + + it("leitet Bearer weiter, auch ohne userId/tenant, und sendet keine X-User-Id", async () => { + let capturedHeaders: Headers | undefined; + global.fetch = vi.fn(async (_url: string | URL, init?: RequestInit) => { + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + const res = await GET(new NextRequest("http://localhost/api/tasks", { headers: { authorization: "Bearer tok" } })); + expect(res.status).toBe(200); + expect(capturedHeaders?.get("authorization")).toBe("Bearer tok"); + expect(capturedHeaders?.get("x-user-id")).toBeNull(); + }); }); diff --git a/web/workflow-tasks/app/api/tasks/route.ts b/web/workflow-tasks/app/api/tasks/route.ts index de558414..65a0570f 100644 --- a/web/workflow-tasks/app/api/tasks/route.ts +++ b/web/workflow-tasks/app/api/tasks/route.ts @@ -3,6 +3,7 @@ // (app/api/case-context/route.ts) als Query-Parameter mitgegeben, bis zu // einer zentralen Session-Anbindung. import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../lib/forward"; function backendURL(): string { const base = process.env.WORKFLOW_TASKS_API_URL; @@ -13,16 +14,17 @@ function backendURL(): string { } export async function GET(request: NextRequest) { + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); const tenantSlug = request.nextUrl.searchParams.get("tenant"); - if (!userId || !tenantSlug) { + if (!bearer && (!userId || !tenantSlug)) { return NextResponse.json({ error: "'userId' und 'tenant' sind erforderlich" }, { status: 400 }); } let backendResponse: Response; try { backendResponse = await fetch(`${backendURL()}/tasks`, { - headers: { "X-User-Id": userId, "X-Tenant-Slug": tenantSlug }, + headers: bearer ? { Authorization: bearer } : { "X-User-Id": userId as string, "X-Tenant-Slug": tenantSlug as string }, cache: "no-store", }); } catch (err) { diff --git a/web/workflow-tasks/app/page.tsx b/web/workflow-tasks/app/page.tsx index b398cbdb..dad93ba9 100644 --- a/web/workflow-tasks/app/page.tsx +++ b/web/workflow-tasks/app/page.tsx @@ -1,14 +1,21 @@ "use client"; -import { Suspense } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { TextField } from "@nexarch/shl"; import { TaskBoard } from "./TaskBoard"; +import { bearerEnabled, getSession, sessionUserId } from "../lib/http"; function TasksPage() { const params = useSearchParams(); - const userId = params.get("user") ?? ""; - const tenantSlug = params.get("tenant") ?? ""; + const [sessionIdentity, setSessionIdentity] = useState({ userId: "", tenant: "" }); + useEffect(() => { + sessionUserId() + .then((userId) => setSessionIdentity({ userId, tenant: getSession().identity()?.tenant ?? "" })) + .catch(() => {}); + }, []); + const userId = bearerEnabled() ? sessionIdentity.userId : (params.get("user") ?? ""); + const tenantSlug = bearerEnabled() ? sessionIdentity.tenant : (params.get("tenant") ?? ""); return (
diff --git a/web/workflow-tasks/lib/api.ts b/web/workflow-tasks/lib/api.ts index d421bf27..82636d75 100644 --- a/web/workflow-tasks/lib/api.ts +++ b/web/workflow-tasks/lib/api.ts @@ -2,6 +2,8 @@ // app/api — kein direkter Zugriff auf workflow/internal/taskapi vom Client // aus, gleiches Muster wie web/routing-rules/web/workflow-cases. +import { apiFetch } from "./http"; + export class ApiError extends Error { status: number; constructor(message: string, status: number) { @@ -43,7 +45,7 @@ async function handle(res: Response): Promise { } export async function listTasks(userId: string, tenantSlug: string): Promise { - const res = await fetch( + const res = await apiFetch( `/api/tasks?userId=${encodeURIComponent(userId)}&tenant=${encodeURIComponent(tenantSlug)}`, { cache: "no-store" } ); @@ -56,7 +58,7 @@ export async function setTaskStatus( id: string, status: TaskStatus ): Promise { - const res = await fetch( + const res = await apiFetch( `/api/tasks/${encodeURIComponent(id)}/status?userId=${encodeURIComponent(userId)}&tenant=${encodeURIComponent(tenantSlug)}`, { method: "PATCH", diff --git a/web/workflow-tasks/lib/forward.ts b/web/workflow-tasks/lib/forward.ts new file mode 100644 index 00000000..1a128bf9 --- /dev/null +++ b/web/workflow-tasks/lib/forward.ts @@ -0,0 +1,6 @@ +import type { NextRequest } from "next/server"; + +export function bearerHeader(request: NextRequest): string | null { + const auth = request.headers.get("authorization") ?? ""; + return auth.startsWith("Bearer ") && auth.length > 7 ? auth : null; +} diff --git a/web/workflow-tasks/lib/http.ts b/web/workflow-tasks/lib/http.ts new file mode 100644 index 00000000..46286a65 --- /dev/null +++ b/web/workflow-tasks/lib/http.ts @@ -0,0 +1,29 @@ +import { createSession, redirectToLogin, type Session } from "@nexarch/shl"; + +let session: Session | undefined; + +export function bearerEnabled(): boolean { + return Boolean(process.env.NEXT_PUBLIC_CORE_API_BASE); +} + +export function getSession(): Session { + if (!session) { + session = createSession({ + coreBase: process.env.NEXT_PUBLIC_CORE_API_BASE as string, + module: "workflow", + onLoginRequired: () => redirectToLogin(process.env.NEXT_PUBLIC_LOGIN_URL), + }); + } + return session; +} + +export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return bearerEnabled() ? getSession().fetch(input, init) : fetch(input, init); +} + +export async function sessionUserId(): Promise { + if (!bearerEnabled()) return ""; + const s = getSession(); + await s.token(); + return s.identity()?.userId ?? ""; +} -- 2.47.3 From 77b46945f2754754c2763c6fa7355f22967e3541 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 28 Oct 2026 15:05:34 +0100 Subject: [PATCH 3/3] workflow-cases, routing-rules: Sitzung und Bearer statt X-User-Id aus der URL Co-Authored-By: Claude Sonnet 5.5 --- .../api/routing-rules/[id]/enabled/route.ts | 6 ++-- .../api/routing-rules/[id]/priority/route.ts | 6 ++-- .../app/api/routing-rules/route.test.ts | 23 +++++++++++++++ .../app/api/routing-rules/route.ts | 11 ++++--- .../app/api/routing-rules/test/route.ts | 6 ++-- web/routing-rules/app/page.tsx | 9 ++++-- web/routing-rules/lib/api.ts | 12 ++++---- web/routing-rules/lib/forward.ts | 6 ++++ web/routing-rules/lib/http.ts | 29 +++++++++++++++++++ .../app/api/cases/[id]/overview/route.ts | 6 ++-- .../app/api/cases/route.test.ts | 23 +++++++++++++++ web/workflow-cases/app/api/cases/route.ts | 6 ++-- .../app/api/reminders/[id]/move/route.ts | 6 ++-- .../app/api/reminders/kanban/route.ts | 6 ++-- web/workflow-cases/app/page.tsx | 9 ++++-- web/workflow-cases/lib/api.ts | 10 ++++--- web/workflow-cases/lib/forward.ts | 6 ++++ web/workflow-cases/lib/http.ts | 29 +++++++++++++++++++ 18 files changed, 178 insertions(+), 31 deletions(-) create mode 100644 web/routing-rules/lib/forward.ts create mode 100644 web/routing-rules/lib/http.ts create mode 100644 web/workflow-cases/lib/forward.ts create mode 100644 web/workflow-cases/lib/http.ts diff --git a/web/routing-rules/app/api/routing-rules/[id]/enabled/route.ts b/web/routing-rules/app/api/routing-rules/[id]/enabled/route.ts index 6faae02f..b1e6e94c 100644 --- a/web/routing-rules/app/api/routing-rules/[id]/enabled/route.ts +++ b/web/routing-rules/app/api/routing-rules/[id]/enabled/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../../../lib/forward"; function backendURL(): string { const base = process.env.ROUTING_RULES_API_URL; @@ -10,8 +11,9 @@ function backendURL(): string { export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } const payload = await request.json().catch(() => null); @@ -22,7 +24,7 @@ export async function PUT(request: NextRequest, context: { params: Promise<{ id: try { backendResponse = await fetch(`${backendURL()}/routing-rules/${encodeURIComponent(id)}/enabled`, { method: "PUT", - headers: { "Content-Type": "application/json", "X-User-Id": userId }, + headers: { "Content-Type": "application/json", ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }) }, body: JSON.stringify(payload), }); } catch (err) { diff --git a/web/routing-rules/app/api/routing-rules/[id]/priority/route.ts b/web/routing-rules/app/api/routing-rules/[id]/priority/route.ts index 4024d66d..7de54597 100644 --- a/web/routing-rules/app/api/routing-rules/[id]/priority/route.ts +++ b/web/routing-rules/app/api/routing-rules/[id]/priority/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../../../lib/forward"; function backendURL(): string { const base = process.env.ROUTING_RULES_API_URL; @@ -10,8 +11,9 @@ function backendURL(): string { export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } const payload = await request.json().catch(() => null); @@ -22,7 +24,7 @@ export async function PUT(request: NextRequest, context: { params: Promise<{ id: try { backendResponse = await fetch(`${backendURL()}/routing-rules/${encodeURIComponent(id)}/priority`, { method: "PUT", - headers: { "Content-Type": "application/json", "X-User-Id": userId }, + headers: { "Content-Type": "application/json", ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }) }, body: JSON.stringify(payload), }); } catch (err) { diff --git a/web/routing-rules/app/api/routing-rules/route.test.ts b/web/routing-rules/app/api/routing-rules/route.test.ts index a37e56b7..818aab30 100644 --- a/web/routing-rules/app/api/routing-rules/route.test.ts +++ b/web/routing-rules/app/api/routing-rules/route.test.ts @@ -58,3 +58,26 @@ describe("POST /api/routing-rules", () => { expect(res.status).toBe(400); }); }); + + +describe("Bearer routing-rules", () => { + const originalFetch = global.fetch; + const originalEnv = process.env.ROUTING_RULES_API_URL; + afterEach(() => { + global.fetch = originalFetch; + process.env.ROUTING_RULES_API_URL = originalEnv; + }); + + it("leitet Authorization weiter und sendet keine X-User-Id", async () => { + process.env.ROUTING_RULES_API_URL = "http://backend.local"; + let h: Headers | undefined; + global.fetch = vi.fn(async (_url: string | URL, init?: RequestInit) => { + h = new Headers(init?.headers); + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + const res = await GET(new NextRequest("http://localhost/api/routing-rules?userId=fremd", { headers: { authorization: "Bearer tok" } })); + expect(res.status).toBe(200); + expect(h?.get("authorization")).toBe("Bearer tok"); + expect(h?.get("x-user-id")).toBeNull(); + }); +}); diff --git a/web/routing-rules/app/api/routing-rules/route.ts b/web/routing-rules/app/api/routing-rules/route.ts index 82d44866..da77f414 100644 --- a/web/routing-rules/app/api/routing-rules/route.ts +++ b/web/routing-rules/app/api/routing-rules/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../lib/forward"; function backendURL(): string { const base = process.env.ROUTING_RULES_API_URL; @@ -13,14 +14,15 @@ function requireUserID(request: NextRequest): string | null { } export async function GET(request: NextRequest) { + const bearer = bearerHeader(request); const userId = requireUserID(request); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } let backendResponse: Response; try { backendResponse = await fetch(`${backendURL()}/routing-rules`, { - headers: { "X-User-Id": userId }, + headers: { ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }) }, cache: "no-store", }); } catch (err) { @@ -34,8 +36,9 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { + const bearer = bearerHeader(request); const userId = requireUserID(request); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } const payload = await request.json().catch(() => null); @@ -46,7 +49,7 @@ export async function POST(request: NextRequest) { try { backendResponse = await fetch(`${backendURL()}/routing-rules`, { method: "POST", - headers: { "Content-Type": "application/json", "X-User-Id": userId }, + headers: { "Content-Type": "application/json", ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }) }, body: JSON.stringify(payload), }); } catch (err) { diff --git a/web/routing-rules/app/api/routing-rules/test/route.ts b/web/routing-rules/app/api/routing-rules/test/route.ts index 9df3d850..0045bc02 100644 --- a/web/routing-rules/app/api/routing-rules/test/route.ts +++ b/web/routing-rules/app/api/routing-rules/test/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../../lib/forward"; function backendURL(): string { const base = process.env.ROUTING_RULES_API_URL; @@ -9,8 +10,9 @@ function backendURL(): string { } export async function POST(request: NextRequest) { + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } const payload = await request.json().catch(() => null); @@ -21,7 +23,7 @@ export async function POST(request: NextRequest) { try { backendResponse = await fetch(`${backendURL()}/routing-rules/test`, { method: "POST", - headers: { "Content-Type": "application/json", "X-User-Id": userId }, + headers: { "Content-Type": "application/json", ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }) }, body: JSON.stringify(payload), }); } catch (err) { diff --git a/web/routing-rules/app/page.tsx b/web/routing-rules/app/page.tsx index 27e1620b..8a5a98ae 100644 --- a/web/routing-rules/app/page.tsx +++ b/web/routing-rules/app/page.tsx @@ -1,13 +1,18 @@ "use client"; -import { Suspense } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { TextField } from "@nexarch/shl"; import { RuleEditor } from "./RuleEditor"; +import { bearerEnabled, sessionUserId } from "../lib/http"; function RulesPage() { const params = useSearchParams(); - const userId = params.get("user") ?? ""; + const [sessionUser, setSessionUser] = useState(""); + useEffect(() => { + sessionUserId().then(setSessionUser).catch(() => {}); + }, []); + const userId = bearerEnabled() ? sessionUser : (params.get("user") ?? ""); return (
diff --git a/web/routing-rules/lib/api.ts b/web/routing-rules/lib/api.ts index 3f178141..2fde1303 100644 --- a/web/routing-rules/lib/api.ts +++ b/web/routing-rules/lib/api.ts @@ -2,6 +2,8 @@ // app/api — kein direkter Zugriff auf die Workflow-API vom Client aus, // gleiches Muster wie web/workflow-cases/web/workflow-approvals. +import { apiFetch } from "./http"; + export class ApiError extends Error { status: number; constructor(message: string, status: number) { @@ -60,12 +62,12 @@ async function handle(res: Response): Promise { } export async function listRules(userId: string): Promise { - const res = await fetch(`/api/routing-rules?userId=${encodeURIComponent(userId)}`, { cache: "no-store" }); + const res = await apiFetch(`/api/routing-rules?userId=${encodeURIComponent(userId)}`, { cache: "no-store" }); return handle(res); } export async function createRule(userId: string, input: { name: string; priority: number; conditions: Condition[]; actions: Action[] }): Promise { - const res = await fetch(`/api/routing-rules?userId=${encodeURIComponent(userId)}`, { + const res = await apiFetch(`/api/routing-rules?userId=${encodeURIComponent(userId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), @@ -74,7 +76,7 @@ export async function createRule(userId: string, input: { name: string; priority } export async function setPriority(userId: string, id: string, priority: number): Promise { - const res = await fetch(`/api/routing-rules/${encodeURIComponent(id)}/priority?userId=${encodeURIComponent(userId)}`, { + const res = await apiFetch(`/api/routing-rules/${encodeURIComponent(id)}/priority?userId=${encodeURIComponent(userId)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ priority }), @@ -85,7 +87,7 @@ export async function setPriority(userId: string, id: string, priority: number): } export async function setEnabled(userId: string, id: string, enabled: boolean): Promise { - const res = await fetch(`/api/routing-rules/${encodeURIComponent(id)}/enabled?userId=${encodeURIComponent(userId)}`, { + const res = await apiFetch(`/api/routing-rules/${encodeURIComponent(id)}/enabled?userId=${encodeURIComponent(userId)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }), @@ -96,7 +98,7 @@ export async function setEnabled(userId: string, id: string, enabled: boolean): } export async function testRule(userId: string, object: Record, history?: string[]): Promise { - const res = await fetch(`/api/routing-rules/test?userId=${encodeURIComponent(userId)}`, { + const res = await apiFetch(`/api/routing-rules/test?userId=${encodeURIComponent(userId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ object, history }), diff --git a/web/routing-rules/lib/forward.ts b/web/routing-rules/lib/forward.ts new file mode 100644 index 00000000..1a128bf9 --- /dev/null +++ b/web/routing-rules/lib/forward.ts @@ -0,0 +1,6 @@ +import type { NextRequest } from "next/server"; + +export function bearerHeader(request: NextRequest): string | null { + const auth = request.headers.get("authorization") ?? ""; + return auth.startsWith("Bearer ") && auth.length > 7 ? auth : null; +} diff --git a/web/routing-rules/lib/http.ts b/web/routing-rules/lib/http.ts new file mode 100644 index 00000000..46286a65 --- /dev/null +++ b/web/routing-rules/lib/http.ts @@ -0,0 +1,29 @@ +import { createSession, redirectToLogin, type Session } from "@nexarch/shl"; + +let session: Session | undefined; + +export function bearerEnabled(): boolean { + return Boolean(process.env.NEXT_PUBLIC_CORE_API_BASE); +} + +export function getSession(): Session { + if (!session) { + session = createSession({ + coreBase: process.env.NEXT_PUBLIC_CORE_API_BASE as string, + module: "workflow", + onLoginRequired: () => redirectToLogin(process.env.NEXT_PUBLIC_LOGIN_URL), + }); + } + return session; +} + +export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return bearerEnabled() ? getSession().fetch(input, init) : fetch(input, init); +} + +export async function sessionUserId(): Promise { + if (!bearerEnabled()) return ""; + const s = getSession(); + await s.token(); + return s.identity()?.userId ?? ""; +} diff --git a/web/workflow-cases/app/api/cases/[id]/overview/route.ts b/web/workflow-cases/app/api/cases/[id]/overview/route.ts index 321f3292..d4c4c494 100644 --- a/web/workflow-cases/app/api/cases/[id]/overview/route.ts +++ b/web/workflow-cases/app/api/cases/[id]/overview/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../../../lib/forward"; function backendURL(): string { const base = process.env.WORKFLOW_CASES_API_URL; @@ -10,15 +11,16 @@ function backendURL(): string { export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } let backendResponse: Response; try { backendResponse = await fetch(`${backendURL()}/cases/${encodeURIComponent(id)}/overview`, { - headers: { "X-User-Id": userId }, + headers: { ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }) }, cache: "no-store", }); } catch (err) { diff --git a/web/workflow-cases/app/api/cases/route.test.ts b/web/workflow-cases/app/api/cases/route.test.ts index e2c72528..5d7b9bc0 100644 --- a/web/workflow-cases/app/api/cases/route.test.ts +++ b/web/workflow-cases/app/api/cases/route.test.ts @@ -39,3 +39,26 @@ describe("GET /api/cases", () => { expect(capturedHeaders?.get("X-User-Id")).toBe("alice"); }); }); + + +describe("Bearer cases", () => { + const originalFetch = global.fetch; + const originalEnv = process.env.WORKFLOW_CASES_API_URL; + afterEach(() => { + global.fetch = originalFetch; + process.env.WORKFLOW_CASES_API_URL = originalEnv; + }); + + it("leitet Authorization weiter und sendet keine X-User-Id", async () => { + process.env.WORKFLOW_CASES_API_URL = "http://backend.local"; + let h: Headers | undefined; + global.fetch = vi.fn(async (_url: string | URL, init?: RequestInit) => { + h = new Headers(init?.headers); + return new Response(JSON.stringify([]), { status: 200 }); + }) as typeof fetch; + const res = await GET(new NextRequest("http://localhost/api/cases?userId=fremd", { headers: { authorization: "Bearer tok" } })); + expect(res.status).toBe(200); + expect(h?.get("authorization")).toBe("Bearer tok"); + expect(h?.get("x-user-id")).toBeNull(); + }); +}); diff --git a/web/workflow-cases/app/api/cases/route.ts b/web/workflow-cases/app/api/cases/route.ts index e262e23a..4ec10b6f 100644 --- a/web/workflow-cases/app/api/cases/route.ts +++ b/web/workflow-cases/app/api/cases/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../lib/forward"; function backendURL(): string { const base = process.env.WORKFLOW_CASES_API_URL; @@ -9,8 +10,9 @@ function backendURL(): string { } export async function GET(request: NextRequest) { + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } const status = request.nextUrl.searchParams.get("status") ?? ""; @@ -22,7 +24,7 @@ export async function GET(request: NextRequest) { let backendResponse: Response; try { backendResponse = await fetch(`${backendURL()}/cases?${params.toString()}`, { - headers: { "X-User-Id": userId }, + headers: { ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }) }, cache: "no-store", }); } catch (err) { diff --git a/web/workflow-cases/app/api/reminders/[id]/move/route.ts b/web/workflow-cases/app/api/reminders/[id]/move/route.ts index 9dcb1f77..22e03046 100644 --- a/web/workflow-cases/app/api/reminders/[id]/move/route.ts +++ b/web/workflow-cases/app/api/reminders/[id]/move/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../../../lib/forward"; function backendURL(): string { const base = process.env.WORKFLOW_CASES_API_URL; @@ -10,8 +11,9 @@ function backendURL(): string { export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } const payload = await request.json().catch(() => null); @@ -23,7 +25,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ try { backendResponse = await fetch(`${backendURL()}/reminders/${encodeURIComponent(id)}/move`, { method: "POST", - headers: { "X-User-Id": userId, "Content-Type": "application/json" }, + headers: { ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }), "Content-Type": "application/json" }, body: JSON.stringify({ target: payload.target }), }); } catch (err) { diff --git a/web/workflow-cases/app/api/reminders/kanban/route.ts b/web/workflow-cases/app/api/reminders/kanban/route.ts index aa5dc0de..c6fcd7b7 100644 --- a/web/workflow-cases/app/api/reminders/kanban/route.ts +++ b/web/workflow-cases/app/api/reminders/kanban/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { bearerHeader } from "../../../../lib/forward"; function backendURL(): string { const base = process.env.WORKFLOW_CASES_API_URL; @@ -9,15 +10,16 @@ function backendURL(): string { } export async function GET(request: NextRequest) { + const bearer = bearerHeader(request); const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (!bearer && !userId) { return NextResponse.json({ error: "'userId' ist erforderlich" }, { status: 400 }); } let backendResponse: Response; try { backendResponse = await fetch(`${backendURL()}/reminders/kanban`, { - headers: { "X-User-Id": userId }, + headers: { ...(bearer ? { Authorization: bearer } : { "X-User-Id": userId as string }) }, cache: "no-store", }); } catch (err) { diff --git a/web/workflow-cases/app/page.tsx b/web/workflow-cases/app/page.tsx index 4fffe95b..91f00a01 100644 --- a/web/workflow-cases/app/page.tsx +++ b/web/workflow-cases/app/page.tsx @@ -1,16 +1,21 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { TextField } from "@nexarch/shl"; import { CaseOverview } from "./CaseOverview"; import { ReminderKanban } from "./ReminderKanban"; +import { bearerEnabled, sessionUserId } from "../lib/http"; type Tab = "cases" | "kanban"; function CasesPage() { const params = useSearchParams(); - const userId = params.get("user") ?? ""; + const [sessionUser, setSessionUser] = useState(""); + useEffect(() => { + sessionUserId().then(setSessionUser).catch(() => {}); + }, []); + const userId = bearerEnabled() ? sessionUser : (params.get("user") ?? ""); const [tab, setTab] = useState("cases"); return ( diff --git a/web/workflow-cases/lib/api.ts b/web/workflow-cases/lib/api.ts index b555d895..bf671ff0 100644 --- a/web/workflow-cases/lib/api.ts +++ b/web/workflow-cases/lib/api.ts @@ -3,6 +3,8 @@ // (Rolle: "Alle Daten ausschliesslich ueber die oeffentliche // Schnittstelle beziehen"), gleiches Muster wie web/workflow-approvals. +import { apiFetch } from "./http"; + export class ApiError extends Error { status: number; constructor(message: string, status: number) { @@ -72,24 +74,24 @@ export async function listCases(userId: string, status: string, responsible: str const params = new URLSearchParams({ userId }); if (status) params.set("status", status); if (responsible) params.set("responsible", responsible); - const res = await fetch(`/api/cases?${params.toString()}`, { cache: "no-store" }); + const res = await apiFetch(`/api/cases?${params.toString()}`, { cache: "no-store" }); return parse(res, "Akten konnten nicht geladen werden"); } export async function getCaseDetail(userId: string, id: string): Promise { - const res = await fetch(`/api/cases/${encodeURIComponent(id)}/overview?userId=${encodeURIComponent(userId)}`, { + const res = await apiFetch(`/api/cases/${encodeURIComponent(id)}/overview?userId=${encodeURIComponent(userId)}`, { cache: "no-store", }); return parse(res, "Akte konnte nicht geladen werden"); } export async function listKanban(userId: string): Promise { - const res = await fetch(`/api/reminders/kanban?userId=${encodeURIComponent(userId)}`, { cache: "no-store" }); + const res = await apiFetch(`/api/reminders/kanban?userId=${encodeURIComponent(userId)}`, { cache: "no-store" }); return parse(res, "Wiedervorlagen konnten nicht geladen werden"); } export async function moveReminder(userId: string, id: string, target: KanbanColumn): Promise { - const res = await fetch(`/api/reminders/${encodeURIComponent(id)}/move?userId=${encodeURIComponent(userId)}`, { + const res = await apiFetch(`/api/reminders/${encodeURIComponent(id)}/move?userId=${encodeURIComponent(userId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ target }), diff --git a/web/workflow-cases/lib/forward.ts b/web/workflow-cases/lib/forward.ts new file mode 100644 index 00000000..1a128bf9 --- /dev/null +++ b/web/workflow-cases/lib/forward.ts @@ -0,0 +1,6 @@ +import type { NextRequest } from "next/server"; + +export function bearerHeader(request: NextRequest): string | null { + const auth = request.headers.get("authorization") ?? ""; + return auth.startsWith("Bearer ") && auth.length > 7 ? auth : null; +} diff --git a/web/workflow-cases/lib/http.ts b/web/workflow-cases/lib/http.ts new file mode 100644 index 00000000..46286a65 --- /dev/null +++ b/web/workflow-cases/lib/http.ts @@ -0,0 +1,29 @@ +import { createSession, redirectToLogin, type Session } from "@nexarch/shl"; + +let session: Session | undefined; + +export function bearerEnabled(): boolean { + return Boolean(process.env.NEXT_PUBLIC_CORE_API_BASE); +} + +export function getSession(): Session { + if (!session) { + session = createSession({ + coreBase: process.env.NEXT_PUBLIC_CORE_API_BASE as string, + module: "workflow", + onLoginRequired: () => redirectToLogin(process.env.NEXT_PUBLIC_LOGIN_URL), + }); + } + return session; +} + +export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return bearerEnabled() ? getSession().fetch(input, init) : fetch(input, init); +} + +export async function sessionUserId(): Promise { + if (!bearerEnabled()) return ""; + const s = getSession(); + await s.token(); + return s.identity()?.userId ?? ""; +} -- 2.47.3