+ );
+}
diff --git a/apps/web/components/login-form.tsx b/apps/web/components/login-form.tsx
index 3d076cdf..b2b3a49d 100644
--- a/apps/web/components/login-form.tsx
+++ b/apps/web/components/login-form.tsx
@@ -8,6 +8,7 @@ import { PrimaryAction, StateBanner } from "@easytree/ui";
import { useAuthGateway } from "../lib/auth-gateway-provider";
import { useSession } from "../lib/session-provider";
+import { startShellFuer } from "../lib/feld/start-shell";
/** Vollstaendig, ohne Default-Zweig — dieselbe Regel wie in der Planung. */
const FEHLERTEXT: Record = {
@@ -41,7 +42,11 @@ export function LoginForm() {
}
// Kein Optimismus: die Sitzung wird NEU vom Server geladen, dann Weiterleitung.
neuLaden();
- router.push("/kosten");
+ // Start-Shell aus der SERVER-Antwort des Logins (EYT-113): das DTO ist die
+ // frisch verifizierte Session, kein Client-Zustand. Leitungsrollen landen
+ // wie bisher in der Werkbank, reine member in der Feld-App — und das
+ // Server-Gate unter /feld prueft die Session dort ERNEUT.
+ router.push(startShellFuer(ergebnis.value) === "feld" ? "/feld" : "/kosten");
}
return (
diff --git a/apps/web/app/page.tsx b/apps/web/components/start-inhalt.tsx
similarity index 69%
rename from apps/web/app/page.tsx
rename to apps/web/components/start-inhalt.tsx
index b6db2e09..2a1e6031 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/components/start-inhalt.tsx
@@ -1,8 +1,14 @@
import { Button, Card } from "@easytree/ui";
-import { ApiStatus } from "../components/api-status";
+import { ApiStatus } from "./api-status";
-export default function HomePage() {
+/**
+ * Inhalt der Werkbank-Startseite — bewusst eine synchrone Komponente ohne
+ * Serverbezug: `app/(werkbank)/page.tsx` entscheidet davor serverseitig
+ * ueber die Start-Shell (EYT-113), und `test/a11y.test.tsx` rendert diesen
+ * Inhalt direkt in jsdom, wo ein async Server Component nicht laufen kann.
+ */
+export function StartInhalt() {
return (
<>
Willkommen bei easyTree
diff --git a/apps/web/e2e/auth-journey/journey.pwtest.ts b/apps/web/e2e/auth-journey/journey.pwtest.ts
index b86d41b3..7199013f 100644
--- a/apps/web/e2e/auth-journey/journey.pwtest.ts
+++ b/apps/web/e2e/auth-journey/journey.pwtest.ts
@@ -70,6 +70,18 @@ const ABNAHME_BREITEN = [
{ name: "1440 px bei 200-%-Zoom (720 px CSS)", width: 720, height: 450 },
] as const;
+/**
+ * EYT-113: Abnahmebreiten der FELD-Shell — mobile-first 320/375 px laut
+ * Akzeptanzkriterium. Bewusst eine eigene Liste: die Werkbank wird auf
+ * Desktopbreiten abgenommen, die Feld-Shell auf Telefonbreiten; eine
+ * gemeinsame Liste wuerde beiden Flaechen Breiten zusichern, fuer die sie
+ * nicht entworfen sind.
+ */
+const FELD_BREITEN = [
+ { name: "320 px", width: 320, height: 640 },
+ { name: "375 px", width: 375, height: 720 },
+] as const;
+
/**
* Tastaturbedienbarkeit und SICHTBARER Fokus auf einer angemeldeten Flaeche
* (EYT-141/EYT-137: „Tastatur, sichtbarer Fokus").
@@ -192,10 +204,14 @@ async function pruefeTastaturUndFokus(seite: Page, flaeche: string): Promise {
+async function pruefeBarrierefreiheit(
+ seite: Page,
+ flaeche: string,
+ breiten: ReadonlyArray<{ name: string; width: number; height: number }> = ABNAHME_BREITEN,
+): Promise {
const urspruenglich = seite.viewportSize();
- for (const breite of ABNAHME_BREITEN) {
+ for (const breite of breiten) {
await seite.setViewportSize({ width: breite.width, height: breite.height });
const proBreite = await new AxeBuilder({ page: seite })
@@ -3038,7 +3054,9 @@ test("Benutzer B ist angemeldet, aber ohne Mitgliedschaft ausgesperrt", async ({
// Schlaegt der Login wirklich fehl, zeigt das Formular einen Banner mit
// role="alert". Auf beides zu warten macht aus einem stillen Timeout
// eine benannte Ursache.
- const angemeldet = seite.waitForURL("**/kosten");
+ // Seit EYT-113 landet ein Konto ohne Leitungsrolle in der Feld-Shell —
+ // B hat gar keine Mitgliedschaft und gehoert damit erst recht dorthin.
+ const angemeldet = seite.waitForURL("**/feld");
const abgelehnt = seite
.getByRole("alert")
.filter({ hasText: "Anmeldung fehlgeschlagen" })
@@ -3053,6 +3071,10 @@ test("Benutzer B ist angemeldet, aber ohne Mitgliedschaft ausgesperrt", async ({
// berechtigt ist er nicht — das ist der Unterschied, um den es geht.
const kekse = await kontext.cookies();
expect(kekse.find((k) => k.name === "eyt_access")?.httpOnly).toBe(true);
+
+ // Und die Feld-Shell bleibt ehrlich: ohne Mitgliedschaft gibt es keine
+ // fachliche Flaeche, sondern den benannten Leerzustand (EYT-113).
+ await expect(seite.getByTestId("feld-ohne-organisation")).toBeVisible();
});
await test.step("die Sitzung nennt Bs eigene Id, nicht die von A", async () => {
@@ -3214,3 +3236,156 @@ test("Benutzer B ist angemeldet, aber ohne Mitgliedschaft ausgesperrt", async ({
await kontext.close();
}
});
+
+/**
+ * EYT-113 — die Feld-Reise: ein realer member erreicht aufgrund seiner
+ * serverseitig verifizierten Session die Mitarbeiter-Feld-Shell; die Werkbank
+ * bleibt ihm verschlossen, und zwar am RECHT, nicht an der Route.
+ *
+ * Die member-Mitgliedschaft ist dieselbe Leihgabe wie in 9c5/9g2
+ * (`eyt136-member-an.sql`/`-aus.sql`): kein neuer Benutzer, `auth.users`
+ * bleibt unberuehrt, und die Nachbedingung der Rueckgabe ist der primaere
+ * Waechter gegen eine ueberlebende Leihgabe. Der Aufruf steht im `try`,
+ * die Rueckgabe laeuft auf jedem Weg (Begruendung woertlich bei 9c5).
+ */
+test("EYT-113: ein member erreicht die Feld-Shell, die Werkbank bleibt zu", async ({ browser }) => {
+ const emailB = pflicht("EASYTREE_JOURNEY_EMAIL_B");
+ const passwortB = pflicht("EASYTREE_JOURNEY_PASSWORT_B");
+ const idA = pflicht("EASYTREE_JOURNEY_USER_A");
+ const idB = pflicht("EASYTREE_JOURNEY_USER_B");
+ const verwaltung = pflicht("EASYTREE_JOURNEY_ADMIN_DB_URL");
+
+ const kontext = await browser.newContext();
+ const seite = await kontext.newPage();
+ const bericht: Record = { ticket: "EYT-113", benutzer: "B als member" };
+ ZUSAMMENFASSUNGEN.set(test.info().testId, { datei: "zusammenfassung-feld.json", bericht });
+
+ let fehlerAusFall: [unknown] | null = null;
+ try {
+ const an = psqlMitMarker(
+ verwaltung,
+ join(HIER, "eyt136-member-an.sql"),
+ ["-v", `benutzer_a=${idA}`, "-v", `benutzer_b=${idB}`],
+ "[eyt136-member-an]",
+ );
+ console.log(` ${an}`);
+
+ await test.step("Login fuehrt einen member in die Feld-Shell", async () => {
+ await seite.goto("/anmelden");
+ await seite.getByLabel("E-Mail").fill(emailB);
+ await seite.getByLabel("Passwort").fill(passwortB);
+ await seite.getByRole("button", { name: "Anmelden" }).click();
+
+ const angekommen = seite.waitForURL("**/feld");
+ const abgelehnt = seite
+ .getByRole("alert")
+ .filter({ hasText: "Anmeldung fehlgeschlagen" })
+ .waitFor({ state: "visible" });
+ await Promise.race([angekommen, abgelehnt]);
+ await expect(
+ seite.getByRole("alert").filter({ hasText: "Anmeldung fehlgeschlagen" }),
+ ).toHaveCount(0);
+ await angekommen;
+
+ // Die Shell nennt die REALEN Sessiondaten — Organisation und Rolle aus
+ // der serverseitig aufgeloesten Sitzung, keine Attrappe.
+ await expect(seite.getByTestId("feld-shell")).toBeVisible();
+ await expect(seite.getByTestId("feld-org")).toHaveText(ORG_NAME);
+ await expect(seite.getByTestId("feld-rolle")).toHaveText("Mitarbeiter");
+ bericht["landung"] = { url: seite.url() };
+ });
+
+ await test.step("die Feld-Shell zeigt keine Werkbank-Navigation", async () => {
+ await expect(seite.getByRole("link", { name: "Planung" })).toHaveCount(0);
+ await expect(seite.getByRole("link", { name: "Kosten" })).toHaveCount(0);
+ const werkbankLinks = await seite.evaluate(
+ () => document.querySelectorAll('a[href="/planung"], a[href="/kosten"]').length,
+ );
+ expect(werkbankLinks).toBe(0);
+ bericht["keine_werkbank_navigation"] = true;
+ });
+
+ await test.step("auch die Startseite leitet den member serverseitig ins Feld", async () => {
+ await seite.goto("/");
+ await seite.waitForURL("**/feld");
+ await expect(seite.getByTestId("feld-shell")).toBeVisible();
+ bericht["start_dispatch"] = true;
+ });
+
+ await test.step("Feld-Shell bei 320/375 px: axe, Reflow, Tastatur, Fokus", async () => {
+ await pruefeBarrierefreiheit(seite, "/feld", FELD_BREITEN);
+
+ // Touch-Ziel nach Basisdesign v2.0 §2.3 (mindestens 40 px): das eine
+ // reale Bedienelement der Shell, gemessen auf der kleinsten Breite.
+ await seite.setViewportSize({ width: 320, height: 640 });
+ const abmelden = await seite.getByTestId("feld-abmelden").boundingBox();
+ expect(abmelden).not.toBeNull();
+ expect(abmelden!.height).toBeGreaterThanOrEqual(40);
+
+ mkdirSync(ARTEFAKTE, { recursive: true });
+ await seite.screenshot({ path: join(ARTEFAKTE, "04-feld-shell-320.png") });
+ await seite.setViewportSize({ width: 375, height: 720 });
+ await seite.screenshot({ path: join(ARTEFAKTE, "05-feld-shell-375.png") });
+ bericht["responsive"] = { breiten: [320, 375], touchziel_abmelden_px: abmelden!.height };
+ });
+
+ await test.step("Cross-Shell: die Werkbank gibt dem member nichts preis", async () => {
+ // B ist jetzt member GENAU EINER Organisation: der Zustand auf /planung
+ // ist Forbidden — nicht "Organisation erforderlich" wie bei B ohne
+ // Mitgliedschaft (dort geprueft) und nicht der Kosteninhalt (9g2 prueft
+ // die Kostenflaeche desselben members).
+ await seite.goto(`/planung?weekKey=${PLANWOCHE}`);
+ await expect(seite.getByTestId("planung-forbidden")).toBeVisible();
+ await expect(seite.getByTestId("planungsfenster-stand")).toHaveCount(0);
+ await expect(seite.getByTestId("planung-veroeffentlichen")).toHaveCount(0);
+
+ // Der Server lehnt unabhaengig von der Oberflaeche ab — 403, nicht 400:
+ // mit genau einer aktiven Mitgliedschaft ist die Organisation eindeutig,
+ // es fehlt das RECHT.
+ const fenster = await seite.request.get(`/api/v1/planung/fenster?weekKey=${PLANWOCHE}`);
+ expect(fenster.status()).toBe(403);
+ expect(await fenster.text()).not.toContain(MITARBEITER_NAME);
+ const mitarbeiter = await seite.request.get("/api/v1/kosten/mitarbeiter");
+ expect(mitarbeiter.status()).toBe(403);
+ expect(await mitarbeiter.text()).not.toContain(MITARBEITER_NAME);
+ bericht["cross_shell"] = {
+ planung_fenster: fenster.status(),
+ kosten_mitarbeiter: mitarbeiter.status(),
+ erwartet: 403,
+ };
+ });
+ } catch (fehler) {
+ fehlerAusFall = [fehler];
+ }
+
+ // Rueckgabe der Leihgabe auf JEDEM Weg — die Nachbedingung in
+ // `eyt136-member-aus.sql` (leihe/b_gesamt = 0) ist der primaere Waechter.
+ const aus = psqlMitMarker(
+ verwaltung,
+ join(HIER, "eyt136-member-aus.sql"),
+ ["-v", `benutzer_a=${idA}`, "-v", `benutzer_b=${idB}`],
+ "[eyt136-member-aus]",
+ );
+ console.log(` ${aus}`);
+ await kontext.close();
+ if (fehlerAusFall !== null) throw fehlerAusFall[0];
+});
+
+/**
+ * EYT-113 — fail-closed ohne Sitzung: /feld ist keine oeffentliche Flaeche.
+ * Das Server-Gate (app/feld/layout.tsx) fragt die API und leitet Abgemeldete
+ * zur Anmeldung, BEVOR irgendein Shell-Inhalt ausgeliefert wird.
+ */
+test("EYT-113: ohne Sitzung fuehrt /feld zur Anmeldung", async ({ browser }) => {
+ const kontext = await browser.newContext();
+ const seite = await kontext.newPage();
+ try {
+ await seite.goto("/feld");
+ await seite.waitForURL("**/anmelden");
+ await expect(seite.getByRole("heading", { name: "Anmelden", level: 1 })).toBeVisible();
+ await expect(seite.getByTestId("feld-shell")).toHaveCount(0);
+ await expect(seite.getByTestId("feld-org")).toHaveCount(0);
+ } finally {
+ await kontext.close();
+ }
+});
diff --git a/apps/web/e2e/shell-smoke.spec.ts b/apps/web/e2e/shell-smoke.spec.ts
index fc6f7407..1236a63b 100644
--- a/apps/web/e2e/shell-smoke.spec.ts
+++ b/apps/web/e2e/shell-smoke.spec.ts
@@ -501,3 +501,24 @@ for (const pfad of ["/", "/planung", "/kosten"]) {
}
});
}
+
+/**
+ * EYT-113: /feld ohne API — das Server-Gate weiss NICHTS und handelt das
+ * fail-closed: ehrliche Fehlerflaeche statt Shell, und KEINE Umleitung zur
+ * Anmeldung. Nichtwissen ist nicht "abgemeldet" — eine Umleitung wuerde bei
+ * einem API-Ausfall angemeldete Nutzer aussperren. Der Prozess haelt die
+ * Route; genau dieses Verhalten unterscheidet den Ausfall vom 401.
+ */
+test("EYT-113: /feld ohne API zeigt die ehrliche Fehlerflaeche, keine Shell", async ({ page }) => {
+ await page.goto("/feld");
+ await expect(page.getByTestId("feld-sitzung-unbekannt")).toBeVisible();
+ expect(new URL(page.url()).pathname).toBe("/feld");
+ await expect(page.getByTestId("feld-shell")).toHaveCount(0);
+ await expect(page.getByTestId("feld-abmelden")).toHaveCount(0);
+ // Die Fehlerflaeche ist selbst zugaenglich: eine main-Landmark, und die
+ // Meldung ist assertiv. Bewusst NICHT getByRole("alert") ohne Filter:
+ // Nexts Route-Announcer traegt ebenfalls role=alert (strict-mode-Treffer,
+ // gemessen 28.08.2026) — geprueft wird das Element der Fehlerflaeche.
+ await expect(page.getByRole("main")).toBeVisible();
+ await expect(page.getByTestId("feld-sitzung-unbekannt")).toHaveAttribute("role", "alert");
+});
diff --git a/apps/web/lib/feld/sitzung-server.ts b/apps/web/lib/feld/sitzung-server.ts
new file mode 100644
index 00000000..bf54ab0d
--- /dev/null
+++ b/apps/web/lib/feld/sitzung-server.ts
@@ -0,0 +1,74 @@
+import { API_BASE_PATH, SessionDtoSchema, type SessionDto } from "@easytree/contracts";
+
+import { aktuellesProxyziel } from "../proxy-durchreichen";
+
+/**
+ * Serverseitige Sitzungslesung (EYT-113).
+ *
+ * Server Components (Feld-Gate, Start-Dispatch) fragen die echte API nach der
+ * Session — mit den Cookies der eingehenden Anfrage, gegen dasselbe
+ * Laufzeit-Proxyziel, das auch `lib/proxy-durchreichen.ts` benutzt. Die
+ * Umgebung wird hier bewusst NICHT gelesen: `aktuellesProxyziel()` ist die
+ * eine erlaubte Lesestelle (secret-surface-Waechter, EYT-126).
+ *
+ * Drei Zustaende, fail-closed:
+ * angemeldet — 200 und der Body besteht das strikte SessionDto-Schema
+ * abgemeldet — die API antwortet 401 (verifiziertes Nichtangemeldetsein)
+ * unbekannt — alles andere. Nichtwissen ist NICHT abgemeldet: ein
+ * API-Ausfall darf angemeldete Nutzer nicht zur Anmeldung
+ * umleiten, er zeigt eine ehrliche Fehlerflaeche.
+ */
+export type ServerSitzung =
+ | { zustand: "angemeldet"; session: SessionDto }
+ | { zustand: "abgemeldet" }
+ | { zustand: "unbekannt" };
+
+export async function leseServerSitzung(
+ cookieHeader: string | null,
+ fetchImpl: typeof fetch = fetch,
+ zielLeser: () => string = aktuellesProxyziel,
+): Promise {
+ let ziel: string;
+ try {
+ ziel = zielLeser();
+ } catch {
+ return { zustand: "unbekannt" };
+ }
+
+ const headers = new Headers({ accept: "application/json" });
+ if (cookieHeader !== null && cookieHeader !== "") {
+ headers.set("cookie", cookieHeader);
+ }
+
+ let antwort: Response;
+ try {
+ antwort = await fetchImpl(`${ziel}${API_BASE_PATH}/auth/session`, {
+ method: "GET",
+ headers,
+ cache: "no-store",
+ redirect: "manual",
+ });
+ } catch {
+ return { zustand: "unbekannt" };
+ }
+
+ if (antwort.status === 401) {
+ return { zustand: "abgemeldet" };
+ }
+ if (antwort.status !== 200) {
+ return { zustand: "unbekannt" };
+ }
+
+ let body: unknown;
+ try {
+ body = await antwort.json();
+ } catch {
+ return { zustand: "unbekannt" };
+ }
+
+ const geparst = SessionDtoSchema.safeParse(body);
+ if (!geparst.success) {
+ return { zustand: "unbekannt" };
+ }
+ return { zustand: "angemeldet", session: geparst.data };
+}
diff --git a/apps/web/lib/feld/start-shell.ts b/apps/web/lib/feld/start-shell.ts
new file mode 100644
index 00000000..54e52660
--- /dev/null
+++ b/apps/web/lib/feld/start-shell.ts
@@ -0,0 +1,26 @@
+import type { SessionDto } from "@easytree/contracts";
+
+/**
+ * Start-Shell-Ableitung (EYT-113).
+ *
+ * Die zulaessige Start-Shell folgt aus der serverseitig verifizierten Session,
+ * konkret aus den Mitgliedschaftsrollen: Wer in KEINER Organisation eine
+ * Leitungsrolle (owner/manager) traegt, arbeitet im Feld. Eine einzige
+ * Leitungsrolle genuegt fuer die Werkbank, denn nur dort existieren die
+ * Flaechen, die diese Rolle bedienen kann.
+ *
+ * Bewusst Rollen statt Permissions: `role_permissions` ist globale
+ * Konfiguration und kann wachsen (z. B. kuenftige Mitarbeiter-Rechte fuer
+ * EYT-81); die Rollentrennung member/leitung bleibt davon unberuehrt.
+ *
+ * Ein Nutzer ohne jede Mitgliedschaft faellt ins Feld: die kleinste Flaeche,
+ * auf der jede Ansicht ohne Organisation ehrlich leer bleibt.
+ */
+export type StartShell = "feld" | "werkbank";
+
+export function startShellFuer(session: SessionDto): StartShell {
+ const leitung = session.organisations.some(
+ (organisation) => organisation.role === "owner" || organisation.role === "manager",
+ );
+ return leitung ? "werkbank" : "feld";
+}
diff --git a/apps/web/test/a11y.test.tsx b/apps/web/test/a11y.test.tsx
index 24f33d42..6858d8f2 100644
--- a/apps/web/test/a11y.test.tsx
+++ b/apps/web/test/a11y.test.tsx
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { HttpAuthGateway, type CostSnapshot, type CostsGateway } from "@easytree/contracts";
import RootLayout from "../app/layout";
-import HomePage from "../app/page";
+import { StartInhalt } from "../components/start-inhalt";
import { AppShell } from "../components/app-shell";
import { KostenAnsicht } from "../components/kosten-ansicht";
import { createApiClient } from "../lib/api-client";
@@ -51,7 +51,7 @@ function renderShell() {
-
+
diff --git a/apps/web/test/feld-shell.test.tsx b/apps/web/test/feld-shell.test.tsx
new file mode 100644
index 00000000..35f861f0
--- /dev/null
+++ b/apps/web/test/feld-shell.test.tsx
@@ -0,0 +1,113 @@
+import { cleanup, render } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import axe from "axe-core";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { HttpAuthGateway } from "@easytree/contracts";
+
+import { FeldShell } from "../components/feld/feld-shell";
+import { FeldStart } from "../components/feld/feld-start";
+import { AuthGatewayProvider } from "../lib/auth-gateway-provider";
+import { SessionProvider } from "../lib/session-provider";
+
+// Die Feld-Shell ist eine Client-Komponente mit Router-Bezug; jsdom hat
+// keinen App-Router, also liefern die Hooks feste Werte (Muster a11y.test).
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/feld",
+ useRouter: () => ({ push: vi.fn() }),
+}));
+
+afterEach(cleanup);
+
+const MEMBER_SITZUNG = {
+ userId: "00000000-0000-4000-8000-000000000001",
+ organisations: [
+ {
+ id: "00000000-0000-4000-8000-000000000002",
+ name: "Baumpflege Nord",
+ role: "member",
+ permissions: [],
+ },
+ ],
+};
+
+/**
+ * Feld-Shell samt Startseite mit realem SessionProvider gegen einen
+ * gestubbten Auth-Endpunkt — kein Mock der eigenen Logik, nur des Netzes.
+ */
+function renderFeld(sessionBody: unknown = MEMBER_SITZUNG, status = 200) {
+ const auth = new HttpAuthGateway("http://api.test/api/v1", {
+ fetchImpl: async () =>
+ ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => sessionBody,
+ }) as unknown as Response,
+ });
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe("Feld-Shell (EYT-113)", () => {
+ it("zeigt einem member Organisation, Rolle und Abmelden — und hat null axe-Verstoesse", async () => {
+ const ansicht = renderFeld();
+ // findBy wartet auf den Sitzungs-Effekt (Client laedt die Session nach).
+ expect((await ansicht.findByTestId("feld-org")).textContent).toContain("Baumpflege Nord");
+ expect(await ansicht.findByTestId("feld-abmelden")).toBeTruthy();
+ expect((await ansicht.findByTestId("feld-rolle")).textContent).toContain("Mitarbeiter");
+
+ const results = await axe.run(ansicht.container, {
+ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] },
+ // Kontrast braucht echtes Rendering — Teil der manuellen Checkliste.
+ rules: { "color-contrast": { enabled: false } },
+ });
+ expect(results.violations).toEqual([]);
+ });
+
+ it("traegt die Pflicht-Landmarken und den Sprunganker als erstes Tab-Ziel", async () => {
+ const ansicht = renderFeld();
+ await ansicht.findByTestId("feld-abmelden");
+
+ const main = ansicht.getByRole("main");
+ expect(main.id).toBe("hauptinhalt");
+ expect(ansicht.getByRole("banner")).toBeTruthy();
+
+ const skipLink = ansicht.getByRole("link", { name: /zum hauptinhalt springen/i });
+ expect(skipLink.getAttribute("href")).toBe("#hauptinhalt");
+ const user = userEvent.setup();
+ await user.tab();
+ expect(document.activeElement).toBe(skipLink);
+ });
+
+ it("zeigt KEINE Werkbank-Navigation — keine Links auf /planung oder /kosten", async () => {
+ // Gegenmutation, die diesen Fall rot macht: der Feld-Shell einen
+ // Navigationspunkt auf /planung oder /kosten geben (die Import-Grenze
+ // in architecture.test.ts faengt zusaetzlich den Komponenten-Import).
+ const ansicht = renderFeld();
+ await ansicht.findByTestId("feld-abmelden");
+ expect(
+ ansicht.container.querySelectorAll(
+ 'a[href="/planung"], a[href="/kosten"], a[href^="/kosten/"]',
+ ),
+ ).toHaveLength(0);
+ });
+
+ it("ohne Organisation bleibt die Flaeche ehrlich leer statt fachlich zu raten", async () => {
+ const ansicht = renderFeld({ userId: MEMBER_SITZUNG.userId, organisations: [] });
+ expect(await ansicht.findByTestId("feld-ohne-organisation")).toBeTruthy();
+ expect(ansicht.queryByTestId("feld-rolle")).toBeNull();
+ });
+
+ it("meldet einen Sitzungsfehler assertiv, statt Inhalte zu zeigen", async () => {
+ const ansicht = renderFeld({}, 500);
+ expect(await ansicht.findByTestId("feld-start-fehler")).toBeTruthy();
+ expect(ansicht.queryByTestId("feld-org")).toBeNull();
+ });
+});
diff --git a/apps/web/test/helpers/werkbank.tsx b/apps/web/test/helpers/werkbank.tsx
index 16d86e98..62c40dda 100644
--- a/apps/web/test/helpers/werkbank.tsx
+++ b/apps/web/test/helpers/werkbank.tsx
@@ -39,7 +39,7 @@ import type {
import { render } from "@testing-library/react";
import { useEffect, useState, type ReactNode } from "react";
-import PlanungPage from "../../app/planung/page";
+import PlanungPage from "../../app/(werkbank)/planung/page";
import { Providers } from "../../app/providers";
import { AppShell } from "../../components/app-shell";
import { navigation, navigieren } from "./navigation-attrappe";
diff --git a/apps/web/test/planung-page.test.tsx b/apps/web/test/planung-page.test.tsx
index 669c5f87..2f528a70 100644
--- a/apps/web/test/planung-page.test.tsx
+++ b/apps/web/test/planung-page.test.tsx
@@ -17,7 +17,7 @@
import { render, screen, waitFor, cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import PlanungPage from "../app/planung/page";
+import PlanungPage from "../app/(werkbank)/planung/page";
import { AuthGatewayProvider } from "../lib/auth-gateway-provider";
import { PlanningGatewayProvider } from "../lib/planning-gateway-provider";
import { SessionProvider } from "../lib/session-provider";
diff --git a/apps/web/test/sitzung-server.test.ts b/apps/web/test/sitzung-server.test.ts
new file mode 100644
index 00000000..a3a21f99
--- /dev/null
+++ b/apps/web/test/sitzung-server.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { leseServerSitzung } from "../lib/feld/sitzung-server";
+
+/**
+ * Serverseitige Sitzungslesung (EYT-113): Server Components fragen die echte
+ * API (`GET /auth/session`) mit den Cookies der eingehenden Anfrage. Die
+ * Antwort entscheidet fail-closed in drei Zustaenden:
+ *
+ * angemeldet — 200 und der Body besteht das strikte SessionDto-Schema
+ * abgemeldet — die API sagt 401 (verifiziertes Nichtangemeldetsein)
+ * unbekannt — alles andere (Netzfehler, 5xx, kaputter Body, fehlendes
+ * Proxyziel): Nichtwissen ist NICHT abgemeldet, sonst wuerde
+ * ein API-Ausfall angemeldete Nutzer zur Anmeldung umleiten.
+ *
+ * Gegenmutationen, die diese Suite rot machen: 401 auf "unbekannt" mappen,
+ * 500 auf "abgemeldet" mappen, den Cookie-Header nicht weiterreichen oder
+ * die Schemapruefung durch `await res.json() as SessionDto` ersetzen.
+ */
+const ZIEL = "http://api.intern:3001";
+
+const GUELTIGE_SITZUNG = {
+ userId: "00000000-0000-4000-8000-000000000001",
+ organisations: [
+ {
+ id: "00000000-0000-4000-8000-000000000002",
+ name: "Baumpflege Nord",
+ role: "member",
+ permissions: [],
+ },
+ ],
+};
+
+function antwort(status: number, body: unknown): Response {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ } as unknown as Response;
+}
+
+describe("leseServerSitzung (EYT-113)", () => {
+ it("liefert 'angemeldet' samt geparster Session bei 200 und gueltigem Body", async () => {
+ const fetchImpl = vi.fn(async () => antwort(200, GUELTIGE_SITZUNG));
+ const ergebnis = await leseServerSitzung("eyt_access=abc", fetchImpl, () => ZIEL);
+ expect(ergebnis).toEqual({ zustand: "angemeldet", session: GUELTIGE_SITZUNG });
+ });
+
+ it("fragt das Proxyziel unter dem Vertragspfad und reicht die Cookies weiter", async () => {
+ const fetchImpl = vi.fn(async () => antwort(200, GUELTIGE_SITZUNG));
+ await leseServerSitzung("eyt_access=abc; eyt_refresh=def", fetchImpl, () => ZIEL);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
+ expect(url).toBe("http://api.intern:3001/api/v1/auth/session");
+ expect(new Headers(init.headers).get("cookie")).toBe("eyt_access=abc; eyt_refresh=def");
+ expect(init.cache).toBe("no-store");
+ });
+
+ it("sendet ohne eingehende Cookies keinen Cookie-Header", async () => {
+ const fetchImpl = vi.fn(async () => antwort(401, {}));
+ await leseServerSitzung(null, fetchImpl, () => ZIEL);
+ const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
+ expect(new Headers(init.headers).has("cookie")).toBe(false);
+ });
+
+ it("liefert 'abgemeldet' bei 401", async () => {
+ const fetchImpl = vi.fn(async () => antwort(401, {}));
+ expect(await leseServerSitzung(null, fetchImpl, () => ZIEL)).toEqual({
+ zustand: "abgemeldet",
+ });
+ });
+
+ it("liefert 'unbekannt' bei 500 — Nichtwissen ist nicht abgemeldet", async () => {
+ const fetchImpl = vi.fn(async () => antwort(500, {}));
+ expect(await leseServerSitzung("eyt_access=abc", fetchImpl, () => ZIEL)).toEqual({
+ zustand: "unbekannt",
+ });
+ });
+
+ it("liefert 'unbekannt', wenn der Body das strikte Schema nicht besteht", async () => {
+ const fetchImpl = vi.fn(async () =>
+ antwort(200, { userId: "kein-uuid-noetig?", organisations: "kaputt" }),
+ );
+ expect(await leseServerSitzung("eyt_access=abc", fetchImpl, () => ZIEL)).toEqual({
+ zustand: "unbekannt",
+ });
+ });
+
+ it("liefert 'unbekannt', wenn fetch selbst scheitert", async () => {
+ const fetchImpl = vi.fn(async () => {
+ throw new Error("ECONNREFUSED");
+ });
+ expect(await leseServerSitzung("eyt_access=abc", fetchImpl, () => ZIEL)).toEqual({
+ zustand: "unbekannt",
+ });
+ });
+
+ it("liefert 'unbekannt', wenn das Proxyziel fehlt oder ungueltig ist", async () => {
+ const fetchImpl = vi.fn(async () => antwort(200, GUELTIGE_SITZUNG));
+ const ergebnis = await leseServerSitzung("eyt_access=abc", fetchImpl, () => {
+ throw new Error("Proxyziel fehlt");
+ });
+ expect(ergebnis).toEqual({ zustand: "unbekannt" });
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/test/start-shell.test.ts b/apps/web/test/start-shell.test.ts
new file mode 100644
index 00000000..02c69381
--- /dev/null
+++ b/apps/web/test/start-shell.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+
+import type { SessionDto } from "@easytree/contracts";
+
+import { startShellFuer } from "../lib/feld/start-shell";
+
+/**
+ * Start-Shell-Ableitung (EYT-113): Welche Shell einem Nutzer beim Einstieg
+ * gehoert, folgt aus der serverseitig verifizierten Session — konkret aus den
+ * Rollen seiner Mitgliedschaften. Die Funktion ist rein und damit die eine
+ * testbare Wahrheit; Route oder Client-State verleihen kein Recht.
+ *
+ * Gegenmutation, die diese Suite rot macht: `every` durch `some` ersetzen
+ * (dann schickte eine einzige member-Mitgliedschaft auch Leitungen ins Feld)
+ * oder die Rolle "manager" als Feld-Rolle einstufen.
+ */
+function sitzung(rollen: ReadonlyArray<"owner" | "manager" | "member">): SessionDto {
+ return {
+ userId: "00000000-0000-4000-8000-000000000001",
+ organisations: rollen.map((role, index) => ({
+ id: `00000000-0000-4000-8000-00000000000${index + 2}`,
+ name: `Org ${index + 1}`,
+ role,
+ permissions: [],
+ })),
+ };
+}
+
+describe("startShellFuer (EYT-113)", () => {
+ it("schickt eine reine member-Mitgliedschaft in die Feld-Shell", () => {
+ expect(startShellFuer(sitzung(["member"]))).toBe("feld");
+ });
+
+ it("schickt einen owner in die Werkbank", () => {
+ expect(startShellFuer(sitzung(["owner"]))).toBe("werkbank");
+ });
+
+ it("schickt einen manager in die Werkbank", () => {
+ expect(startShellFuer(sitzung(["manager"]))).toBe("werkbank");
+ });
+
+ it("gemischte Mitgliedschaften: eine Leitungsrolle genuegt fuer die Werkbank", () => {
+ expect(startShellFuer(sitzung(["member", "manager"]))).toBe("werkbank");
+ });
+
+ it("mehrere reine member-Mitgliedschaften bleiben Feld", () => {
+ expect(startShellFuer(sitzung(["member", "member"]))).toBe("feld");
+ });
+
+ it("ohne jede Mitgliedschaft gilt die Feld-Shell (kleinste Flaeche)", () => {
+ // Ein authentifizierter Nutzer ohne Organisation (Reisender-B-Fall) hat
+ // in der Werkbank nichts zu suchen: dort waere jede Flaeche verboten.
+ expect(startShellFuer(sitzung([]))).toBe("feld");
+ });
+});