Files
tessera-ctl/.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-08-PLAN.md
T
schalli 8a4bb32847
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 46s
Tessera CI/CD / Build & Publish Images (push) Successful in 6s
docs(15): create phase plan — 8 plans, 4 waves, PERM-01..07
2026-08-04 14:39:46 +02:00

232 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 15-modul-berechtigungen-gruppen-user-grants
plan: 08
type: execute
wave: 4
depends_on: ["15-03", "15-06"]
files_modified:
- apps/web/src/lib/module-access-actions.ts
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx
- apps/web/src/app/(portal)/marketplace/page.tsx
- apps/web/src/app/(portal)/marketplace/[slug]/page.tsx
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx
autonomous: true
requirements: [PERM-04]
user_setup: []
estimate:
tokens: 70000
raw_tokens: 70000
tasks: 2
confidence: low
must_haves:
truths:
- "Ruft ein Benutzer die URL eines nicht freigegebenen Moduls direkt auf, erscheint eine 403-Seite mit dem Hinweis, sich an den Administrator zu wenden — kein stiller Rücksprung und keine Nicht-gefunden-Seite (D-07, PERM-04)."
- "Die Prüfung passiert serverseitig in der Modulseiten-Route, bevor irgendein Modulcode ausgeliefert wird; das Ausblenden in der Sidebar ist ausdrücklich keine Zugriffskontrolle (D-07)."
- "Der Marketplace zeigt auch nicht freigegebene Module weiter, gekennzeichnet mit einem Badge 'Kein Zugriff'; öffnen lassen sie sich nicht (D-08)."
- "Der Modulkatalog bleibt für jeden authentifizierten Benutzer offen — der Marketplace ist ein Schaufenster, keine Zugriffsentscheidung (D-08)."
- "ADMIN und SUPER_ADMIN sehen das Sperr-Badge nie; für sie ist jede aktivierte Karte normal anklickbar (D-03)."
- "Sidebar, Modulseite und API stützen sich auf dieselbe Zugriffsauflösung — die Modulseite fragt sie über die API ab, statt eine eigene Regel zu bauen (D-01, PERM-04)."
- "Die serverseitige Prüfung schliesst im Zweifel: fehlt das Sitzungs-Cookie oder ist die API nicht erreichbar, gilt der Zugriff als nicht gewährt."
- "Der bisherige Inhalt der Modulseite — die Whitelist-Registrierung, das Nachladen der Modulkomponente und der Nicht-gefunden-Zustand für unregistrierte Slugs — bleibt unverändert erhalten und wandert nur in eine eigene Client-Komponente."
# --- UI-SPEC 'UI Considerations' — covered ---
- "empty, loading, error, populated und zero-one-many des Marketplace-Katalogs bleiben unverändert gegenüber dem ausgelieferten Stand — dieser Plan ergänzt ausschliesslich das Badge und den nicht anklickbaren Zustand einer einzelnen Karte."
- "empty, loading und error der 403-Seite und des Aktivierungsdialogs: beide sind reine Zustandsanzeigen ohne eigenen Datenabruf; die 403-Seite rendert serverseitig fertig, Fehler beim Aktivieren laufen über den bestehenden Fehlerpfad der Modulseite."
# --- UI-SPEC 'UI Considerations' — backstop ---
- statement: "long-text und overflow (Marketplace-Karte mit langem Modulnamen plus zusätzlichem Sperr-Badge): Die Badge-Reihe bricht bei schmaler Karte um, statt aus der Karte zu laufen; die Titelkürzung deckt nur den Titel ab, nicht die Badge-Reihe."
verification: backstop
- statement: "partial (Marketplace-Karte, bevor der Freigabe-Status bekannt ist): Aktivierungs- und Freigabestatus kommen aus derselben Antwort, sodass keine Karte kurzzeitig ohne Sperr-Badge anklickbar erscheint und erst nachträglich sperrt."
verification: backstop
artifacts:
- "apps/web/src/lib/module-access-actions.ts — checkModuleAccess"
- "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx — Server Component mit 403-Zustand"
- "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx"
- "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx"
- "MarketplaceCard mit hasAccess-Prop und Sperr-Badge"
key_links:
- "page.tsx → checkModuleAccess → GET /modules/active — die Modulseite fragt dieselbe Auflösung ab, die auch Guard und Sidebar bedienen"
- "MarketplaceCard → GET /modules/catalog — beide Statusflags kommen in einer Antwort, damit kein Zwischenzustand entsteht"
- "module-shell.tsx → module-loader Whitelist — der Sicherheitsmechanismus gegen beliebige Slugs bleibt unverändert bestehen"
prohibitions:
- statement: "Sichtbarkeit ist nie Zugriffskontrolle — kein Ausblenden in Sidebar, Marketplace oder Grid darf als Sperre gelten; jede Sperre muss serverseitig durchgesetzt sein und clientseitig nur gespiegelt werden."
status: active
verification: flagged-unverified
---
<objective>
Die beiden Oberflächen, an denen ein Benutzer ohne Freigabe tatsächlich anschlägt: die Modulseite, die serverseitig sperrt und eine erklärende 403-Seite rendert, und der Marketplace-Katalog, der gesperrte Module weiter zeigt, aber als solche kennzeichnet.
Purpose: Die Sidebar zeigt seit Plan 15-01 nur noch freigegebene Module — das ist Bequemlichkeit, keine Sperre. Wer die URL kennt oder ein Lesezeichen hat, kommt weiterhin auf die Seite. D-07 verlangt deshalb ausdrücklich eine serverseitige Prüfung in der Modulseiten-Route selbst. Die heutige Datei ist vollständig eine Client-Komponente ohne serverseitigen Datenabruf und erfüllt das nicht.
Output: Eine Server-Action für den Zugriffscheck, die zur Server-Komponente umgebaute Modulseite mit ausgelagerter Client-Hülle, und die Marketplace-Karte mit drittem Zustand.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-CONTEXT.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-PATTERNS.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md
@.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-06-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Serverseitige Modulsperre mit 403-Seite</name>
<files>apps/web/src/lib/module-access-actions.ts, apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx, apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx, apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx</files>
<read_first>
- apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx — die komplette Datei: der Nicht-gefunden-Block (Zeilen 30–64) als visuelle Vorlage für den 403-Zustand, die Whitelist-Prüfung gegen die Modulregistrierung und der Rücknavigations-Block
- apps/web/src/lib/auth-actions.ts — Zeilen 243–268: `fetchCurrentUser` als vollständiges Kopiermuster für die Cookie-Weiterleitung in einer Server-Action, inklusive des fehlschlagenden Pfads
- apps/web/src/lib/module-loader.ts — die Whitelist und `loadModuleComponent`, deren Verhalten unverändert bleiben muss
- apps/web/src/messages/de.json — die in Plan 15-06 angelegten Schlüssel unter `modules.accessDenied`
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 4 und der Copywriting Contract zur 403-Seite
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-RESEARCH.md — Pattern 5 zum Server-Komponenten-Schnitt
</read_first>
<action>
Lege `apps/web/src/lib/module-access-actions.ts` an, als Server-Modul mit der Direktive am Dateikopf, analog zu `auth-actions.ts`. Exportiere `checkModuleAccess(moduleSlug: string): Promise<boolean>`. Die Funktion liest das Sitzungs-Cookie über `cookies()` und ruft `GET /modules/active` mit weitergeleitetem Cookie-Header, `credentials: 'include'` und `cache: 'no-store'` auf — exakt das Muster von `fetchCurrentUser`. Aus der Antwort wird geprüft, ob der gesuchte Slug enthalten ist.
Die Funktion schliesst im Zweifel: fehlt das Cookie, antwortet die API nicht erfolgreich oder wirft der Aufruf, ist das Ergebnis falsch. Ein Fehler im Netzwerkpfad darf nie zu einem offenen Zugang führen.
Es wird bewusst der bestehende Endpoint wiederverwendet und kein eigener Zugriffs-Endpoint eingeführt. Die Modulanzahl je Mandant liegt im einstelligen bis niedrigen zweistelligen Bereich, der Umweg über die Liste ist damit unkritisch, und es entsteht kein zweiter Vertrag über dieselbe Frage.
Baue `page.tsx` in eine asynchrone Server-Komponente um. Die Datei trägt danach keine Client-Direktive mehr, liest `params` und ruft `checkModuleAccess(moduleSlug)` auf.
Ist der Zugriff nicht gewährt, rendert die Seite unmittelbar das 403-Markup als Server-Antwort. Sie ruft dabei weder die Next.js-Weiche für nicht gefundene Seiten noch eine Umleitung auf — D-07 schliesst beides ausdrücklich aus, weil der Benutzer erfahren soll, dass das Modul existiert und ihm die Freigabe fehlt, statt es für nicht vorhanden zu halten oder wortlos woanders zu landen.
Das 403-Markup übernimmt die Struktur des bestehenden Nicht-gefunden-Blocks eins zu eins: zentrierter Block, Icon-Container mit `rounded-lg bg-muted p-4`, eine Überschrift in 18 Pixel und Gewicht 600, ein Absatz in 14 Pixel `text-muted-foreground`, darunter ein Link. Nur zwei Dinge ändern sich. Erstens das Icon: statt des Kreises mit Schrägstrich ein Schloss-Motiv im selben SVG-Vokabular, damit "existiert nicht" und "gesperrt" optisch unterscheidbar bleiben. Zweitens Text und Ziel: Überschrift aus `modules.accessDenied.title`, Text aus `modules.accessDenied.body`, und ein Link auf die Startseite mit `modules.accessDenied.backToDashboard` — nicht zurück in die Modulkategorie, weil ein Rücksprung dorthin für ein Modul ohne Freigabe kein sinnvoller nächster Schritt ist.
Ist der Zugriff gewährt, rendert die Seite `<ModuleShell category={category} moduleSlug={moduleSlug} />`.
Lege `module-shell.tsx` als Client-Komponente an und verschiebe den bisherigen Inhalt von `page.tsx` unverändert dorthin: die Whitelist-Prüfung gegen die Modulregistrierung, das Nachladen der Modulkomponente, den Nicht-gefunden-Zustand für unregistrierte Slugs und den Rücknavigations-Block. Der Sicherheitskommentar zur Whitelist wandert mit. An diesem Verhalten wird nichts geändert: die Whitelist verhindert weiterhin, dass ein beliebiger Slug aus der URL einen Import auslöst, und ist eine von der Freigabeprüfung unabhängige zweite Absicherung. Statt der Hook-basierten Parameterauflösung bekommt die Komponente `category` und `moduleSlug` als Props.
Lege `module-access.test.tsx` an mit Tests für: bei verweigertem Zugriff erscheinen Titel und Text der 403-Seite und die Modulhülle wird nicht gerendert; bei gewährtem Zugriff wird die Modulhülle gerendert; bei fehlendem Sitzungs-Cookie ist das Ergebnis von `checkModuleAccess` falsch; bei einer nicht erfolgreichen API-Antwort ist das Ergebnis falsch; ein unregistrierter Slug bei gewährtem Zugriff führt weiterhin in den Nicht-gefunden-Zustand der Modulhülle.
</action>
<verify>
<automated>pnpm --filter @tessera/web test -- module-access</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @tessera/web test -- module-access` ist grün und enthält je einen Test für die fünf oben genannten Fälle.
- `grep -c "'use client'" "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx"` gibt `0` aus.
- `grep -c "'use client'" "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx"` gibt `1` aus.
- `grep -c 'notFound(\|redirect(' "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx"` gibt `0` aus.
- `grep -c 'checkModuleAccess' "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx" apps/web/src/lib/module-access-actions.ts` belegt Aufruf und Definition.
- `grep -c 'accessDenied' "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx"` gibt mindestens `2` aus (Titel und Text).
- `grep -c 'MODULE_REGISTRY' "apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx"` gibt mindestens `1` aus — die Whitelist ist unverändert mitgewandert.
- Manuell: als USER ohne Freigabe die URL eines aktivierten Moduls direkt aufrufen; die Antwort ist die 403-Seite mit Schloss-Icon und Link auf die Startseite. Im Netzwerk-Reiter ist erkennbar, dass kein Modul-Bundle nachgeladen wurde.
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
</acceptance_criteria>
<done>Der direkte URL-Aufruf eines nicht freigegebenen Moduls endet serverseitig in einer erklärenden 403-Seite, ohne dass Modulcode ausgeliefert wird, und der bestehende Whitelist-Schutz ist unverändert erhalten.</done>
</task>
<task type="auto">
<name>Task 2: Marketplace-Karte mit drittem Zustand "Kein Zugriff"</name>
<files>apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx, apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx, apps/web/src/app/(portal)/marketplace/page.tsx, apps/web/src/app/(portal)/marketplace/[slug]/page.tsx</files>
<read_first>
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx — die komplette Datei: die Props-Schnittstelle, die Badge-Reihe (Zeilen 96–110) und der Kartenzustand samt Klickverhalten (Zeilen 88, 120–138)
- apps/web/src/app/(portal)/marketplace/page.tsx — Zeilen 34–60: der zweigeteilte Datenabruf und die Ableitung des Aktivierungszustands aus der Antwort
- apps/web/src/app/(portal)/marketplace/[slug]/page.tsx — Zeilen 45–60: derselbe zweigeteilte Abruf auf der Detailseite
- apps/web/src/app/(portal)/marketplace/components/Toast.tsx — die bestehende Toast-Komponente, die unverändert wiederverwendet wird
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx — die bestehende Testsuite, die erweitert wird
- apps/web/src/messages/de.json — die in Plan 15-06 angelegten Schlüssel `marketplace.statusNoAccess` und `marketplace.toastNoAccess`
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 5 und die Badge-Palette im Abschnitt Color
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-03-SUMMARY.md — das Antwortformat von `GET /modules/catalog`
</read_first>
<action>
Stelle den Datenabruf in `marketplace/page.tsx` und in `marketplace/[slug]/page.tsx` auf den einen Aufruf `GET /modules/catalog` um, der je Modul den vollständigen Datensatz plus die beiden Flags für mandantenweite Aktivierung und Benutzerzugriff liefert. Die bisherige Kombination aus zwei Aufrufen entfällt.
Der Wechsel ist notwendig, nicht kosmetisch: seit Plan 15-01 antwortet `GET /modules/active` benutzergefiltert, und aus dieser Antwort allein könnte der Marketplace für einen USER nicht mehr zwischen "nicht aktiviert" und "aktiviert, aber nicht freigegeben" unterscheiden. Genau diese Unterscheidung ist der Kern von D-08.
Dass beide Flags aus derselben Antwort kommen, löst zugleich den Zwischenzustand: es gibt keinen Moment, in dem eine Karte bereits gerendert und anklickbar ist, ihr Sperrhinweis aber noch fehlt und nachträglich erscheint.
Erweitere `MarketplaceCard` um die Prop `hasAccess: boolean`. Bei `isActive && !hasAccess` gilt: ein zusätzliches drittes Badge mit dem Text aus `marketplace.statusNoAccess` erscheint in derselben Badge-Reihe, in Bernstein (`bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400`). Bernstein und nicht Rot ist eine bewusste Wahl: es ist kein Fehlerzustand, sondern ein Informationszustand.
Die Badge-Reihe bekommt zusätzlich Umbruchverhalten. Der bestehende Container fasst zwei Badges nebeneinander; mit einem dritten und einem langen Modulnamen läuft er bei schmaler Karte über. Die Titelkürzung deckt nur den Titel ab, nicht die Badge-Reihe — deshalb bricht die Reihe um, statt aus der Karte zu laufen.
Der äussere Kartencontainer bekommt in diesem Zustand `opacity-60 cursor-not-allowed` und verliert die Hover-Effekte, damit sichtbar ist, dass die Karte nicht interaktiv ist. Ein Klick navigiert nicht zum Moduldetail, sondern löst über die bestehende Toast-Komponente den Text aus `marketplace.toastNoAccess` aus — wortgleich zur 403-Seite, damit derselbe Sachverhalt nicht zwei Formulierungen bekommt. Es entsteht kein neues Toast-System.
Für ADMIN und SUPER_ADMIN ist `hasAccess` bei jedem aktiven Modul wahr, weil die API den Rollen-Kurzschluss anwendet. Das Badge erscheint für sie damit nie, ohne dass die Karte selbst eine Rollenabfrage braucht.
Der Katalog bleibt vollständig sichtbar: nicht freigegebene Module werden gekennzeichnet, nicht ausgeblendet. Der Marketplace ist ein Schaufenster, und die Zugriffsentscheidung liegt bei der API.
Erweitere `MarketplaceCard.test.tsx` um Tests für: bei aktivem Modul ohne Zugriff erscheint das dritte Badge und der Klick löst den Toast statt einer Navigation aus; bei aktivem Modul mit Zugriff erscheint das Badge nicht und der Klick navigiert; bei nicht aktiviertem Modul bleibt das bisherige Verhalten unverändert; die Badge-Reihe trägt Umbruchverhalten.
</action>
<verify>
<automated>pnpm --filter @tessera/web test -- MarketplaceCard</automated>
</verify>
<acceptance_criteria>
- `pnpm --filter @tessera/web test -- MarketplaceCard` ist grün und enthält je einen Test für die vier oben genannten Fälle.
- `grep -c 'hasAccess' "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"` gibt mindestens `3` aus (Props-Schnittstelle, Destrukturierung, Verwendung).
- `grep -c 'statusNoAccess' "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"` gibt mindestens `1` aus.
- `grep -c 'amber' "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"` gibt mindestens `2` aus (Hell- und Dunkelvariante).
- `grep -c 'flex-wrap' "apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx"` gibt mindestens `1` aus.
- `grep -c 'modules/catalog' "apps/web/src/app/(portal)/marketplace/page.tsx" "apps/web/src/app/(portal)/marketplace/[slug]/page.tsx"` belegt die Umstellung auf beiden Seiten.
- `grep -c 'fetch(' "apps/web/src/app/(portal)/marketplace/page.tsx"` ist gegenüber dem Stand vor diesem Task um mindestens `1` gesunken — der zweigeteilte Abruf ist durch einen einzigen ersetzt.
- Manuell im Browser als USER: ein aktiviertes Modul ohne Freigabe trägt in der Übersicht das Sperr-Badge, ist ausgegraut und zeigt beim Klick den Hinweis-Toast; dasselbe Modul als ADMIN trägt kein Badge und ist anklickbar. Bei sehr schmalem Fenster bleiben beide Badges innerhalb der Karte.
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
</acceptance_criteria>
<done>Der Marketplace unterscheidet sichtbar zwischen nicht aktiviert, aktiviert-ohne-Freigabe und zugänglich, zeigt gesperrte Module weiterhin an und lässt sie nicht öffnen.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser-URL → Modulseiten-Route | Der Slug in der URL ist Benutzereingabe; die Route entscheidet serverseitig über Auslieferung |
| Next.js-Server → API | Die Server-Action leitet ausschliesslich das Sitzungs-Cookie weiter, keinen selbst gebildeten Identitätsnachweis |
| Marketplace-Karte → Anzeigezustand | Die Sperre der Karte ist Anzeige, nicht Durchsetzung |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-15-05 | Elevation of Privilege | Frontend-seitiges Ausblenden als Sicherheitsgrenze behandeln | high | mitigate | Die Modulseite prüft serverseitig vor dem Rendern und liefert bei fehlender Freigabe kein Modul-Bundle aus; die eigentliche Durchsetzung bleibt der `ModuleGuard` auf jedem Modul-Endpoint. Sidebar- und Marketplace-Zustand sind ausdrücklich nur Spiegelung |
| T-15-29 | Elevation of Privilege | Fehlerhafter Netzwerkpfad in `checkModuleAccess` öffnet die Seite | high | mitigate | Die Funktion schliesst im Zweifel: fehlendes Cookie, nicht erfolgreiche Antwort und geworfener Fehler ergeben alle ein negatives Ergebnis; zwei eigene Testfälle belegen das |
| T-15-30 | Elevation of Privilege | Ein beliebiger Slug aus der URL löst einen Import aus | medium | mitigate | Die Whitelist-Prüfung der Modulregistrierung wandert unverändert in die Client-Hülle und bleibt eine von der Freigabeprüfung unabhängige zweite Absicherung |
| T-15-08 | Information Disclosure | `GET /modules/catalog` zeigt jedem angemeldeten Benutzer den Modulkatalog | low | accept | Bewusste Entscheidung aus D-08: der Katalog bleibt Schaufenster und liefert ausschliesslich Modul-Metadaten sowie zwei Boolesche, keine Modulinhalte |
</threat_model>
<verification>
- `pnpm --filter @tessera/web test` vollständig grün.
- `pnpm --filter @tessera/web run build` fehlerfrei.
- Manuell als USER ohne Freigabe: Modul fehlt in der Sidebar, die direkte URL liefert die 403-Seite, die Marketplace-Karte trägt das Sperr-Badge und der Klick zeigt den Toast, und ein Aufruf des Modul-Endpoints der API antwortet mit HTTP 403. Alle vier Ebenen zeigen dasselbe Bild — das ist der Nachweis von PERM-04.
- Manuell als ADMIN desselben Mandanten: alle vier Ebenen zeigen das Modul als zugänglich.
</verification>
<success_criteria>
- Ohne Freigabe fehlt das Modul in der Sidebar, ist die Modulseite serverseitig gesperrt und antwortet die Modul-API mit 403 (PERM-04).
- Die 403-Seite nennt, was zu tun ist, statt nur zu sperren (D-07).
- Der Marketplace kennzeichnet gesperrte Module, statt sie auszublenden (D-08).
- Kein Zwischenzustand zeigt eine gesperrte Karte kurzzeitig als anklickbar.
</success_criteria>
## Artifacts this phase produces
Von diesem Plan erzeugt beziehungsweise verändert:
- `checkModuleAccess` (`apps/web/src/lib/module-access-actions.ts`)
- `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/page.tsx` — Umbau zur Server-Komponente mit 403-Zustand
- `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-shell.tsx` — die ausgelagerte Client-Hülle
- `apps/web/src/app/(portal)/modules/[category]/[moduleSlug]/module-access.test.tsx`
- `MarketplaceCard` — Prop `hasAccess`, Sperr-Badge, nicht anklickbarer Zustand, Toast statt Navigation
- `marketplace/page.tsx` und `marketplace/[slug]/page.tsx` — Umstellung auf `GET /modules/catalog`
- `MarketplaceCard.test.tsx` — erweitert
Dieser Plan legt keine neuen i18n-Schlüssel an — alle verwendeten Schlüssel entstehen in Plan 15-06. Die phasenweite Gesamtliste steht in `15-01-PLAN.md`.
<output>
Erstelle `.planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-08-SUMMARY.md`, wenn der Plan abgeschlossen ist.
</output>