Die Gruppenverwaltung ist als sechster Eintrag der Admin-Navigation unter /admin/groups erreichbar (D-14).
Ein Admin kann in der Oberfläche Gruppen anlegen, umbenennen, löschen sowie Mitglieder manuell zuweisen und entfernen (PERM-01).
Die AD-Gruppe wird aus einer durchsuchbaren Liste gewählt, nicht als DN abgetippt — die Liste stammt aus der bestehenden LDAP-Gruppen- und OU-Suche (D-18).
Pro Tessera-Gruppe ist genau eine AD-Gruppe wählbar: die Auswahl ist eine Radio-Liste, keine Mehrfachauswahl (D-05).
Der Löschdialog nennt die konkrete Zahl der Mitglieder und der Modul-Freigaben sowie den Hinweis, dass betroffene Benutzer den Zugriff verlieren, sofern sie ihn nicht anderweitig haben (D-17).
Eine Mitgliedschaft mit Herkunft LDAP trägt einen deaktivierten Entfernen-Button mit dem Hinweis, dass sie über den AD-Sync verwaltet wird (D-19).
Die Standardgruppen-Markierung ist ein Sterntoggle je Zeile: sie lässt sich umhängen und abschalten, und die markierte Gruppe bleibt umbenennbar, bindbar und löschbar (D-13).
Alle i18n-Schlüssel dieser Phase liegen nach diesem Plan vollständig in de.json und en.json vor — auch die, die erst die Pläne 15-07 und 15-08 verwenden.
Die Oberfläche führt keine eigene Zugriffsentscheidung: sie zeigt für Rollen unterhalb von ADMIN den bestehenden accessDenied-Zustand und verlässt sich für die Durchsetzung auf den RolesGuard der API.
empty (Gruppenliste): Der leere Zustand rendert die Copy 'Keine Gruppen vorhanden' samt Erklärtext und CTA, nach dem Muster des noUsers-Zustands der Benutzerseite.
loading (Matrix-Zellen-Toggle, Gruppen-Modal-Speichern): Ladezustände nutzen dasselbe Inline-Spinner-Muster wie der bestehende isToggling-Zustand der Modulseite; es entsteht kein neues Ladeverhalten und kein Vollseiten-Ladezustand.
zero-one-many (AD-Bindung): Zwei Zustände sind ausgeführt — ungebunden zeigt die Auswahl-Oberfläche, gebunden zeigt den DN als Chip mit einem Link zum Lösen der Bindung.
zero-one-many (Standardgruppe): Pro Mandant existieren genau null oder eine markierte Gruppe, DB-seitig erzwungen; die Liste zeigt je Zeile einen Sterntoggle.
partial (Gruppenliste, Mitgliederzahl): Es gibt keinen Teilzustand — die Mitgliederzahl kommt in derselben Antwort wie die Gruppenzeile, es existiert kein Nachladepfad, der eine Zeile ohne Zahl rendern könnte.
statement
verification
error (Gruppe löschen schlägt fehl): Der Löschdialog zeigt bei Netzwerk- oder Serverfehler eine sichtbare Fehlermeldung im bestehenden error-Div-Muster und schliesst sich nicht — der stille Fehlschlag der bestehenden Lösch-Dialoge wird hier bewusst nicht fortgeführt, weil der Dialog konkrete Zahlen verspricht, die bei einem unbemerkten Fehlschlag veralten.
backstop
statement
verification
partial (AD-Gruppensuche im Create- und Rename-Modal): Liefert die LDAP-Suche einen Fehler oder ein Teilergebnis, erscheint die Ergebnisliste nicht stillschweigend leer, sondern nutzt den bestehenden Fehler-Div-Pfad der LDAP-Seite.
AdminSidebar-Eintrag → /admin/groups — ohne den Eintrag ist die Seite nur per direkter URL erreichbar
GroupFormModal → GET /ldap/groups — die AD-Auswahl greift auf die bestehende Discovery zurück, es entsteht keine neue LDAP-Logik
DeleteGroupDialog → GET /groups/:id/impact — die konkreten Zahlen aus D-17 kommen aus diesem Aufruf
de.json und en.json → Pläne 15-07 und 15-08 — beide setzen voraus, dass ihre Schlüssel hier bereits angelegt wurden
statement
status
verification
Die Standardgruppe darf nicht zu einem Sonderobjekt werden, das der Admin nicht umbenennen, ummarkieren oder löschen kann — die Markierung ist ein Attribut, kein unveränderlicher Status.
active
flagged-unverified
Die Gruppenverwaltung im Admin-UI: eine neue Seite unter `/admin/groups` mit Tabelle, Anlege- und Umbenennen-Dialog samt AD-Auswahl, Mitglieder-Dialog und einem Löschdialog, der konkrete Zahlen nennt. Dazu bündelt dieser Plan sämtliche i18n-Schlüssel der Phase an einer Stelle.
Purpose: Ohne diese Seite existieren Gruppen nur als API-Objekte. Die i18n-Bündelung hat einen konkreten Grund: de.json und en.json werden von jeder Oberfläche dieser Phase angefasst — würde jeder Frontend-Plan seine eigenen Schlüssel nachtragen, könnten die Pläne 15-07 und 15-08 nicht parallel laufen.
Output: Die Seite mit drei Dialogen, der sechste Navigationseintrag, die vollständigen Übersetzungsdateien und eine Komponenten-Testsuite.
@.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-02-SUMMARY.md
Task 1: Alle i18n-Schlüssel der Phase und der sechste Admin-Navigationseintrag
apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/components/admin/admin-sidebar.tsx
- apps/web/src/messages/de.json — die bestehenden Namensräume, insbesondere `header.admin`, `admin.users`, `adminModules`, `modules` und `marketplace`
- apps/web/src/messages/en.json — dieselbe Struktur, die deckungsgleich bleiben muss
- apps/web/src/components/admin/admin-sidebar.tsx — Zeilen 14–74: die Item-Array-Struktur mit label, href, show und einem Inline-SVG je Eintrag, sowie das generische Link-Rendering darunter
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — der vollständige Copywriting Contract, aus dem jeder Schlüssel und jeder Text wörtlich stammt
Trage in `de.json` und `en.json` sämtliche Schlüssel ein, die Phase 15 benötigt — auch die, die erst die Pläne 15-07 und 15-08 verwenden. Die deutschen Texte stammen wörtlich aus dem Copywriting Contract des UI-SPEC und werden nicht umformuliert; besonders die 403-Formulierung ist durch D-07 wörtlich vorgegeben. Die englischen Texte sind sinngemässe Übersetzungen im Stil der bestehenden Einträge.
Namensraum `header.admin`: der Schlüssel `groups` mit dem Label der Navigation.
Neuer Namensraum `admin.groups` als Geschwister von `admin.users`: `title`, `create`, `edit`, `delete`, `noGroups`, `noGroupsBody`, `name`, `ldapBinding`, `boundBadge`, `manualBadge`, `defaultGroup`, `setDefault`, `isDefault`, `memberCount`, `actions`, `membersButton`, `saveError`, `deleteError`; die Untergruppe `ldapBind` mit `hint`, `bound`, `unbind`, `searchPlaceholder`, `discoverError`, `noResults`; die Untergruppe `members` mit `title`, `remove`, `ldapManaged`, `sourceManual`, `sourceLdap`, `addTitle`, `searchPlaceholder`, `noMembers`, `addError`; die Untergruppe `deleteConfirm` mit `title` und `body`, wobei `body` die beiden Interpolationsparameter `memberCount` und `grantCount` trägt und den vollständigen Warntext aus D-17 enthält; die Untergruppe `grants` mit `matrixCheckboxLabel`, das die Parameter `module` und `group` trägt.
Ergänzung des bestehenden Namensraums `admin.users` um die Untergruppe `grants` mit `detailsButton`, `modalTitle`, `groupsSection`, `noGroups`, `accessSection`, `moduleColumn`, `viaGroupsColumn`, `directColumn`, `noInheritance`, `noActiveModules`, `directCheckboxLabel` (Parameter `module` und `user`) und `saveError`.
Ergänzung des bestehenden Namensraums `adminModules` um `grantsLink`; die Untergruppe `grants` mit `title`, `searchPlaceholder`, `emptyModules`, `emptyModulesLink`, `adminNote`, `saveError`; die Untergruppe `activationDialog` mit `title` (Parameter `module`), `body`, `grantNow`, `configureLater`, `cancel` und `noDefaultGroupHint`.
Ergänzung des bestehenden Namensraums `modules` um die Untergruppe `accessDenied` mit `title`, `body` und `backToDashboard`.
Ergänzung des bestehenden Namensraums `marketplace` um `statusNoAccess` und `toastNoAccess`.
Ergänze in `admin-sidebar.tsx` einen sechsten Eintrag im Item-Array mit `label: t('admin.groups')`, `href: '/admin/groups'`, `show: true` und einem neuen Inline-SVG. Das Icon muss sich optisch vom bestehenden Personen-Icon des Benutzer-Eintrags unterscheiden — sinnvoll ist ein Listen- beziehungsweise Roster-Motiv. Es folgt exakt dem Icon-Vokabular der Datei: `width="16"`, `height="16"`, `viewBox="0 0 24 24"`, `fill="none"`, `stroke="currentColor"`, `strokeWidth="2"`, `strokeLinecap="round"`, `strokeLinejoin="round"`, kein Fill. Kein bestehendes Icon der Datei wird dupliziert. Das Link-Rendering darunter bleibt unverändert, weil es das Array generisch iteriert.
node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const walk=(o,p='')=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'&&v!==null?walk(v,p+k+'.'):[p+k]);const a=walk(de).sort(),b=walk(en).sort();const miss=a.filter(k=>!b.includes(k)).concat(b.filter(k=>!a.includes(k)));if(miss.length){console.error('Abweichende Schluessel:',miss);process.exit(1)}console.log('i18n deckungsgleich:',a.length,'Schluessel')" && pnpm --filter @tessera/web test
- Das oben genannte Node-Kommando läuft mit Exit-Code 0 durch: `de.json` und `en.json` tragen exakt dieselbe Schlüsselmenge.
- `node -e "const d=require('./apps/web/src/messages/de.json');['admin.groups.deleteConfirm.body','admin.groups.ldapBind.hint','admin.users.grants.directCheckboxLabel','adminModules.grants.adminNote','adminModules.activationDialog.grantNow','modules.accessDenied.title','modules.accessDenied.body','marketplace.statusNoAccess','marketplace.toastNoAccess','header.admin.groups'].forEach(p=>{if(!p.split('.').reduce((o,k)=>o&&o[k],d))throw new Error('fehlt: '+p)});console.log('ok')"` gibt `ok` aus.
- `node -e "const d=require('./apps/web/src/messages/de.json');const b=d.admin.groups.deleteConfirm.body;if(!b.includes('{memberCount}')||!b.includes('{grantCount}'))throw new Error('Interpolationsparameter fehlen');console.log('ok')"` gibt `ok` aus.
- `grep -c "/admin/groups" apps/web/src/components/admin/admin-sidebar.tsx` gibt `1` aus.
- `grep -c "href: '/admin/" apps/web/src/components/admin/admin-sidebar.tsx` gibt `6` aus — die Navigation hat sechs Einträge.
- `pnpm --filter @tessera/web test` läuft vollständig grün.
Alle Texte der Phase liegen in beiden Sprachen deckungsgleich vor, und die Gruppenverwaltung ist über die Admin-Navigation erreichbar.
Task 2: Gruppenübersicht mit Anlege-, Umbenennen- und AD-Bindungs-Dialog
apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx
- apps/web/src/app/(portal)/admin/users/page.tsx — die komplette Datei als Struktur-Vorlage: Seitenkopf mit primärem Button, Tabellenmarkup, Modal-Overlay-Muster, Zugriffsprüfung über den Auth-Store, Fetch- und Fehlerbehandlung
- apps/web/src/app/(portal)/admin/ldap/page.tsx — Zeilen 705–746: die Discovery-Sektion mit Suchfeld und Ergebnisliste, das Vorbild für die AD-Gruppenauswahl; sowie das Fehler-Div-Muster derselben Datei
- apps/web/src/app/(portal)/admin/modules/page.tsx — Zeilen 80–112 und 134–138: das optimistische Toggle-Muster mit Rücksetzen im Fehlerfall und das Fehler-Div
- apps/web/src/messages/de.json — die in Task 1 angelegten Schlüssel unter `admin.groups`
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 1 sowie die Abschnitte Spacing, Typography und Color
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md — die tatsächlichen Routen und Antwortformate der Gruppen-API
Lege `apps/web/src/app/(portal)/admin/groups/page.tsx` als Client-Komponente an, mit demselben Kopf wie die Benutzerseite: `useTranslations`, `useAuthStore`, `API_URL` aus `process.env.NEXT_PUBLIC_API_URL`. Übernimm die Zugriffsprüfung wörtlich aus der Benutzerseite — für Rollen unterhalb von ADMIN wird der bestehende `accessDenied`-Zustand gerendert. Das ist ausdrücklich keine Zugriffskontrolle, sondern nur Anzeige; die Durchsetzung liegt beim RolesGuard der API.
Seitenkopf: `h1` mit `admin.groups.title` in 24 Pixel und Gewicht 700, rechtsbündig der primäre Button `admin.groups.create` im `bg-primary`-Stil des bestehenden `openCreate`-Buttons.
Tabelle im etablierten Markup (`overflow-x-auto rounded-md border border-border`, `thead` mit `bg-muted/50`, `tbody` mit `divide-y divide-border`, Zellen mit `px-4 py-3`) und den Spalten Name, AD-Bindung, Standardgruppe, Mitglieder und Aktionen. Die AD-Bindung erscheint als Badge: bei gesetztem `ldapDn` das Info-Blau der bestehenden Palette mit `admin.groups.boundBadge`, sonst das neutrale Grau mit `admin.groups.manualBadge`. Die Standardgruppe ist ein Icon-Button mit Stern-Motiv im Projektmass `px-2 py-1`, gefüllt bei `isDefault`, mit `aria-label` aus `admin.groups.setDefault` beziehungsweise `admin.groups.isDefault`. Ein Klick sendet `PATCH /groups/:id` mit `isDefault` und aktualisiert optimistisch; im Fehlerfall wird der Zustand auf den Serverwert zurückgesetzt und die Meldung im Fehler-Div angezeigt — dasselbe Muster wie `toggleModule` der Modulseite. Die Mitgliederzahl kommt aus der Listenantwort und wird nicht nachgeladen; es gibt damit keinen Zustand, in dem eine Zeile ohne Zahl erscheint. Die Aktionsspalte trägt drei Textbuttons im Stil `px-2 py-1 text-xs`: Bearbeiten, Mitglieder, Löschen.
Leerer Zustand: rendert `admin.groups.noGroups` als Überschrift und `admin.groups.noGroupsBody` als Erklärtext, nach dem Vorbild des `noUsers`-Zustands der Benutzerseite, mit dem Anlege-Button als Handlungsaufforderung.
Lege `components/GroupFormModal.tsx` an — ein Dialog für Anlegen und Umbenennen im Overlay-Muster `fixed inset-0 z-50 flex items-center justify-center bg-black/50` mit einem Container `w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg`. Feld Name als Pflichtfeld. Darunter der Abschnitt AD-Bindung mit dem Hinweistext `admin.groups.ldapBind.hint`, einem Suchfeld und einer Ergebnisliste, deren Markup 1:1 aus der Discovery-Sektion der LDAP-Seite übernommen wird — mit einer Abweichung: die Einträge sind Radio-Elemente statt Checkboxen, weil pro Tessera-Gruppe genau eine AD-Gruppe gebunden wird. Die Liste wird über den bestehenden Endpoint `GET /ldap/groups` befüllt.
Für die AD-Suche gilt: schlägt der Aufruf fehl oder liefert er nichts, erscheint die Liste nicht stillschweigend leer. Ein Fehler wird im Fehler-Div-Muster der LDAP-Seite angezeigt (`admin.groups.ldapBind.discoverError`), ein leeres Ergebnis als `admin.groups.ldapBind.noResults`. Diese beiden Zustände sind der Grund, warum das UI-SPEC den Punkt als Backstop führt — sie werden hier ausgeführt und sind vom Verifikationslauf gezielt zu prüfen.
Ist die Gruppe bereits gebunden, zeigt der Dialog statt der Auswahl den Text `admin.groups.ldapBind.bound` mit dem DN und daneben `admin.groups.ldapBind.unbind` als Link, der `ldapDn` auf null setzt.
Während eines Speichervorgangs zeigt der Speichern-Button denselben Inline-Spinner-Zustand wie der bestehende `isToggling`-Zustand der Modulseite; ein Vollseiten-Ladezustand entsteht nicht.
Halte dich an das freigegebene Typografie- und Farbvokabular des UI-SPEC: vier Grössen- und Gewichtspaare, kein fünftes Gewicht; die Akzentfarbe ausschliesslich für primäre Buttons, den aktiven Toggle-Zustand und den Fokusring; Destructive ausschliesslich für Löschen und Entfernen sowie für Fehlermeldungstext.
pnpm --filter @tessera/web test -- groups-page
- Die Seite `/admin/groups` rendert im angemeldeten ADMIN-Zustand eine Tabelle mit den fünf beschriebenen Spalten; die Komponententests decken den leeren Zustand, eine befüllte Liste und den Sterntoggle ab.
- `grep -c "type=\"radio\"" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` gibt mindestens `1` aus — die AD-Auswahl ist eine Einfachauswahl.
- `grep -c "type=\"checkbox\"" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` gibt `0` aus.
- `grep -c "ldap/groups" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` gibt mindestens `1` aus — die AD-Liste stammt aus der bestehenden Discovery, es entsteht keine neue LDAP-Logik.
- `grep -c "discoverError\|noResults" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx"` gibt mindestens `2` aus — Fehlerfall und Leerfall der AD-Suche sind beide sichtbar ausgeführt.
- `grep -c "font-bold\|font-semibold\|font-medium" "apps/web/src/app/(portal)/admin/groups/page.tsx"` weist ausschliesslich diese drei Gewichtsklassen nach; `grep -c "font-light\|font-thin\|font-extrabold\|font-black" "apps/web/src/app/(portal)/admin/groups/page.tsx"` gibt `0` aus.
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
Gruppen lassen sich in der Oberfläche anlegen, umbenennen, als Standard markieren und an eine aus einer Liste gewählte AD-Gruppe binden.
Task 3: Mitglieder-Dialog und Löschdialog mit konkreten Zahlen
apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx, apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx
- apps/web/src/app/(portal)/admin/ldap/page.tsx — Zeilen 957–974: das Chip-Markup mit Entfernen-Button; sowie Zeilen 820–877: das Suchfeld mit Checkbox-Ergebnisliste zum Hinzufügen von Benutzern
- apps/web/src/app/(portal)/admin/users/page.tsx — Zeilen 383–405: das bestehende Lösch-Dialog-Muster, das hier um konkrete Zahlen und um sichtbare Fehlerbehandlung erweitert wird
- apps/web/src/app/(portal)/admin/groups/page.tsx — die in Task 2 entstandene Seite, in die beide Dialoge eingehängt werden
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-UI-SPEC.md — Surface Contract 1 sowie der Copywriting Contract zum Löschdialog
- .planning/phases/15-modul-berechtigungen-gruppen-user-grants/15-02-SUMMARY.md — die Routen für Mitglieder und Löschauswirkung samt Antwortformat
Lege `components/GroupMembersModal.tsx` an, geöffnet über den Mitglieder-Button einer Tabellenzeile, im Overlay-Muster mit `max-w-md`. Der Dialog hat zwei Abschnitte.
Erster Abschnitt: die aktuellen Mitglieder als Chip-Liste im Markup der bestehenden Ausschlussliste der LDAP-Seite. Jeder Chip trägt zusätzlich einen Herkunfts-Badge mit `admin.groups.members.sourceManual` beziehungsweise `admin.groups.members.sourceLdap`. Bei Herkunft MANUAL ist der Entfernen-Button aktiv und trägt `aria-label` aus `admin.groups.members.remove`. Bei Herkunft LDAP ist derselbe Button `disabled` und trägt `title` und `aria-label` aus `admin.groups.members.ldapManaged`. Der deaktivierte Zustand ist die sichtbare Umsetzung von D-19: eine über das AD gesteuerte Mitgliedschaft entfernt ausschliesslich der Sync, und der Admin soll den Grund dafür lesen können statt vor einem wirkungslosen Button zu stehen.
Zweiter Abschnitt: ein Suchfeld mit Ergebnisliste der Mandanten-Benutzer zum manuellen Hinzufügen, im Markup der bestehenden Benutzersuche der LDAP-Seite, aber mit `POST /groups/:id/members` als Ziel statt eines LDAP-Imports. Ein bereits vorhandenes Mitglied erneut hinzuzufügen ist folgenlos — die API behandelt das als Upsert, die Oberfläche braucht dafür keine Sonderbehandlung.
Lege `components/DeleteGroupDialog.tsx` an. Beim Öffnen lädt der Dialog `GET /groups/:id/impact` und zeigt Titel und Text aus `admin.groups.deleteConfirm`, wobei `memberCount` und `grantCount` als Interpolationsparameter eingesetzt werden. Der Text nennt beide Zahlen konkret und weist darauf hin, dass betroffene Benutzer den Zugriff verlieren, sofern sie ihn nicht anderweitig haben — eine allgemeine Warnung ohne Zahlen wäre eine Verfehlung von D-17.
Der Löschdialog behandelt Fehler sichtbar. Schlägt `DELETE /groups/:id` fehl, bleibt der Dialog offen und zeigt `admin.groups.deleteError` im Fehler-Div-Muster mit `border-destructive/50 bg-destructive/10`. Damit weicht dieser Dialog bewusst vom Präzedenzfall der bestehenden Lösch-Dialoge ab, die im `catch` still scheitern: dieser Dialog nennt konkrete Zahlen, und ein unbemerkter Fehlschlag würde einen Admin in dem Glauben lassen, die Gruppe samt Freigaben sei weg, während sie weiterhin Zugriff gewährt. Halte die Abweichung als Kommentar an der Komponente fest.
Hänge beide Dialoge in `page.tsx` ein und lade die Gruppenliste nach jeder erfolgreichen Mutation neu, damit Mitgliederzahl und Badges aktuell bleiben.
Ergänze `groups-page.test.tsx` um Komponententests für beide Dialoge: der deaktivierte Entfernen-Button bei LDAP-Herkunft, der aktive bei MANUAL-Herkunft, die Anzeige beider Zahlen im Löschtext, und die sichtbare Fehlermeldung bei fehlgeschlagenem Löschaufruf.
pnpm --filter @tessera/web test -- groups-page
- `pnpm --filter @tessera/web test -- groups-page` ist grün und enthält je einen Test für: deaktivierter Entfernen-Button bei LDAP-Herkunft, aktiver Entfernen-Button bei MANUAL-Herkunft, Löschtext mit beiden eingesetzten Zahlen, sichtbare Fehlermeldung nach fehlgeschlagenem Löschaufruf.
- `grep -c "disabled" "apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx"` gibt mindestens `1` aus.
- `grep -c "ldapManaged" "apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx"` gibt mindestens `1` aus.
- `grep -c "impact" "apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx"` gibt mindestens `1` aus — die Zahlen werden aus der API geladen, nicht geschätzt.
- `grep -c "border-destructive/50 bg-destructive/10" "apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx"` gibt mindestens `1` aus — der Fehlerfall ist sichtbar.
- `pnpm --filter @tessera/web test` läuft vollständig grün und `pnpm --filter @tessera/web run build` schliesst fehlerfrei ab.
Mitglieder lassen sich in der Oberfläche zuweisen und entfernen, AD-gesteuerte Mitgliedschaften sind als solche erkennbar und nicht von Hand entfernbar, und der Löschdialog nennt konkrete Zahlen und meldet Fehlschläge sichtbar.
<threat_model>
Trust Boundaries
Boundary
Description
Browser → Gruppen-API
Die Oberfläche schickt Gruppen- und Benutzer-IDs; jede Prüfung passiert serverseitig, die Rollenprüfung im Browser ist reine Anzeige
Admin-Auswahl → LDAP-DN
Der gewählte AD-Gruppen-DN wird gespeichert und später in einen LDAP-Filter interpoliert
STRIDE Threat Register
Threat ID
Category
Component
Severity
Disposition
Mitigation Plan
T-15-22
Elevation of Privilege
Die clientseitige Rollenprüfung currentUser?.role === 'ADMIN' als Sicherheitsgrenze missverstehen
high
mitigate
Die Prüfung blendet nur die Oberfläche aus; jede aufgerufene Route trägt serverseitig RolesGuard mit @Roles(ADMIN, SUPER_ADMIN) aus Plan 15-02. Ein Kommentar an der Prüfung hält fest, dass sie Anzeige und nicht Durchsetzung ist
T-15-23
Tampering
Ein von Hand eingetippter AD-DN gelangt in den späteren LDAP-Filter
medium
mitigate
Der DN ist ausschliesslich aus der Ergebnisliste von GET /ldap/groups wählbar, es gibt kein freies Texteingabefeld dafür (D-18). Zusätzlich escaped Plan 15-04 den Wert vor jeder Interpolation
T-15-24
Repudiation
Ein fehlgeschlagener Löschvorgang bleibt unbemerkt, der Admin hält die Gruppe für entfernt
medium
mitigate
Der Löschdialog bleibt bei einem Fehlschlag offen und zeigt eine sichtbare Fehlermeldung; der stille Fehlschlag der bestehenden Dialoge wird hier bewusst nicht fortgeführt
</threat_model>
- `pnpm --filter @tessera/web test` vollständig grün.
- `pnpm --filter @tessera/web run build` fehlerfrei.
- Die Schlüsselmengen von `de.json` und `en.json` sind deckungsgleich (Node-Prüfung aus Task 1).
- Manuell im Browser: Gruppe anlegen, umbenennen, Standardmarkierung setzen und wieder abschalten, an eine AD-Gruppe binden und Bindung lösen, Mitglied hinzufügen und entfernen, Löschdialog mit gefüllter Gruppe öffnen und beide Zahlen prüfen. Zusätzlich mit abgeschalteter API prüfen, dass die AD-Suche und der Löschvorgang eine sichtbare Fehlermeldung erzeugen statt stumm zu bleiben.
<success_criteria>
Gruppen und Mitglieder sind vollständig über die Oberfläche verwaltbar (PERM-01).
Der Löschdialog nennt konkrete Zahlen (D-17).
Die AD-Gruppe wird ausgewählt, nicht getippt (D-18).
Alle i18n-Schlüssel der Phase liegen in beiden Sprachen vor, damit 15-07 und 15-08 parallel laufen können.
</success_criteria>
Artifacts this phase produces
Von diesem Plan erzeugt beziehungsweise verändert: