25 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 16-ad-gruppen-synchronisation | 04 | execute | 3 |
|
|
true |
|
|
|
Der Wortlaut aus CONTEXT.md: "Eine lokale Gruppe soll nicht mit einer AD verknuepft werden koennen. Das ist ja Quatsch" — die Trennung der beiden Gruppenarten soll in der Oberflaeche sichtbar sein. Nach diesem Plan ist das bestehende AD-Badge in der Bindungsspalte der alleinige Marker dafuer.
Purpose: Zwei Wege zum selben Ergebnis sind laut D-07 eine Fehlerquelle. Der Dialog wird einfacher, nicht komplizierter.
Output: Umgebauter GroupFormModal, Namensanzeige mit Fallback in der Gruppenliste, neue und bereinigte i18n-Schluessel, angepasste Komponententests.
<artifacts_this_phase_produces> Neu entstehende Symbole in diesem Plan:
| Art | Symbol |
|---|---|
| Typfeld | Group.internalName?: string | null (apps/web/src/app/(portal)/admin/groups/page.tsx) |
| Frontend-State | internalName in GroupFormModal |
| i18n-Schluessel | admin.groups.nameLockedHint, admin.groups.adDnLabel, admin.groups.internalName, admin.groups.internalNameHint, admin.groups.createLdapHint, admin.groups.goToLdap, admin.groups.saveErrorNameTaken |
| Entfernte i18n-Schluessel | der komplette Unterbaum admin.groups.ldapBind mit seinen sechs Schluesseln, in de.json und en.json |
Aus Plan 16-02 uebernommen und hier vorausgesetzt: UpdateGroupDto.internalName, die serverseitige Namenssperre, internalName in der Antwort von GET /groups.
</artifacts_this_phase_produces>
<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/16-ad-gruppen-synchronisation/16-CONTEXT.md @.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md @.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md @.planning/phases/16-ad-gruppen-synchronisation/16-02-SUMMARY.md Task 1: GroupFormModal auf drei Zustaende zurueckbauen (D-03, D-04, D-07) apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json - `apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx` — vollstaendig (255 Zeilen); der zu entfernende Umfang ist das lokale Verzeichnis-Interface am Dateikopf, der gesamte Discovery-State samt Effekt und Filter-Memo, der komplette AD-Bindungs-Block im Formular und der Folge-PATCH im Anlegen-Zweig - `apps/web/src/app/(portal)/admin/modules/grants/page.tsx` Zeilen 180-195 — der Link-Stil, der fuer den neuen Hinweis-Link gilt - `apps/web/src/messages/de.json` Zeilen 375-400 und die gleichen Zeilen in `en.json` — der `admin.groups`-Block - `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Surface Contract 4 und der Copywriting Contract - `.planning/phases/16-ad-gruppen-synchronisation/16-PATTERNS.md` — Abschnitt `GroupFormModal.tsx` Entferne aus `GroupFormModal.tsx` restlos alles, was zur AD-Bindungsauswahl gehoert: das lokal deklarierte Verzeichnis-Eintrags-Interface, den `ldapDn`-State, die vier Discovery-States, die Lade-Funktion samt `useEffect`, das gefilterte Memo, den kompletten Formularblock mit Radio-Liste, Suchfeld, Bindungsanzeige und Bindung-entfernen-Link, sowie den zweiten `PATCH`-Aufruf im Anlegen-Zweig von `handleSubmit`. Der `useCallback`-, `useEffect`- und `useMemo`-Import faellt damit ebenfalls weg, ebenso die `tLdap`-Uebersetzungsinstanz. Passe den Docblock der Komponente an: er beschreibt jetzt einen reinen Anlege-/Umbenennen-Dialog mit optionalem internem Namen, ohne AD-Auswahl.Neuer State: internalName, initialisiert aus group?.internalName ?? ''. Abgeleiteter Zustand isImported = group?.ldapDn != null — die Unterscheidung laeuft ueber die AD-Bindung, nicht ueber den Identitaetsschluessel, damit auch eine Alt-Bindung ohne nachgetragenen Schluessel sofort als importiert gilt.
Drei Renderzustaende im selben Container (max-w-md, fixed inset-0 bg-black/50, unveraendert):
(a) Anlegen (group === null): nur das bestehende Namensfeld-Markup, unveraendert und pflichtig. Darunter ein Hinweisabsatz in text-sm text-muted-foreground mit admin.groups.createLdapHint und einem Inline-Link admin.groups.goToLdap nach /admin/ldap im Stil text-primary hover:underline. handleSubmit reduziert sich in diesem Zweig auf einen einzelnen POST /groups mit { name }.
(b) Bearbeiten, lokale Gruppe (group !== null && !isImported): Namensfeld frei editierbar wie heute, kein Feld fuer den internen Namen — fuer eine lokale Gruppe ist der Name bereits der volle Anzeigename, ein zweites Namensfeld waere redundant und von keiner Entscheidung gedeckt. handleSubmit sendet PATCH /groups/{id} mit { name }.
(c) Bearbeiten, importierte Gruppe (group !== null && isImported): dasselbe Namensfeld-Markup, aber disabled, ohne onChange-Wirkung auf ein zu sendendes Feld, und mit dem AD-Namen als Wert; die bestehende disabled:opacity-50-Konvention macht den Zustand sichtbar. Darunter ein Hinweis in text-xs text-muted-foreground mit admin.groups.nameLockedHint. Darunter eine reine Anzeigezeile in font-mono text-xs text-muted-foreground mit admin.groups.adDnLabel und dem DN — kein Steuerelement, keine Aktion. Darunter das neue Feld "Interner Name" im identischen Eingabefeld-Markup wie das Namensfeld, mit Label admin.groups.internalName, ohne Platzhaltertext (insbesondere ohne firmenspezifisches Beispiel) und mit dem Hinweis admin.groups.internalNameHint darunter. handleSubmit sendet in diesem Zustand PATCH /groups/{id} mit ausschliesslich { internalName } — niemals name, da das Feld gesperrt ist und das Backend aus Plan 16-02 ein name fuer importierte Gruppen mit 400 ablehnt. Ein leerer oder nur aus Leerzeichen bestehender Wert wird als null gesendet.
Fehlerbehandlung: das bereits vorhandene Fehlerbanner bleibt und wird konsequent fuer alle drei Zustaende genutzt. Neu ist die Unterscheidung der Ursache — antwortet der Server mit Status 409, wird admin.groups.saveErrorNameTaken gesetzt, sonst admin.groups.saveError. Der Dialog schliesst in keinem Fehlerfall und setzt keine Eingabe zurueck.
i18n: die sieben neuen Schluessel unter admin.groups in de.json und en.json anlegen, deutsche Texte woertlich aus dem Copywriting Contract, englische sinngleich. Die verwaisten Schluessel werden erst in Task 3 entfernt — bis dahin bleibt der Baum unverletzt, damit ein Zwischenstand keinen fehlenden Schluessel erzeugt.
cd apps/web && npx tsc --noEmit
cd apps/web && npx vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx"
node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');for(const k of ['nameLockedHint','adDnLabel','internalName','internalNameHint','createLdapHint','goToLdap','saveErrorNameTaken']){if(!de.admin.groups[k])throw new Error('de fehlt '+k);if(!en.admin.groups[k])throw new Error('en fehlt '+k)}"
<acceptance_criteria>
- Die Komponente stellt keinen Verzeichnisabruf mehr: grep -c "ldap/groups" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx" ergibt 0
- Kein Radio-Steuerelement mehr: grep -c "type=\"radio\"" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx" ergibt 0
- Kein zweiter Schreibaufruf im Anlegen-Zweig: grep -c "method: 'PATCH'" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx" ergibt 1
- grep -c "internalName" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx" ist >= 4 (State, Feld, Payload, Initialisierung)
- grep -q "disabled" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx" trifft und das Namensfeld traegt es zustandsabhaengig
- grep -q "409" "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx" trifft (Kollisionsfall wird von anderen Fehlern unterschieden)
- cd apps/web && npx tsc --noEmit ist fehlerfrei
- Der Node-Schluesselcheck oben laeuft ohne Wurf durch
</acceptance_criteria>
Der Dialog kennt drei klar getrennte Zustaende, bietet in keinem davon einen Weg zur AD-Bindung, sperrt den Namen importierter Gruppen sichtbar, laesst den internen Namen editieren und zeigt jeden Speicherfehler mit unterscheidbarer Ursache an.
In der Namenszelle den gerenderten Wert auf den internen Namen mit Rueckfall auf den gespeicherten Namen umstellen und den umschliessenden Text in ein <span> mit title={group.name} fassen — damit bleibt der AD-Name per Hover nachvollziehbar, auch wenn ein interner Name gesetzt ist. Verwende die Nullish-Variante, nicht die Oder-Variante: das Backend normalisiert leere Werte bereits auf null, und eine Truthiness-Pruefung wuerde diese Normalisierung nachtraeglich umdeuten. Die Zellklassen bleiben unveraendert.
Die Badge-Zelle daneben bleibt vollstaendig unveraendert. Sie ist nach D-07 der alleinige visuelle Marker fuer "importiert vs. lokal"; es entsteht kein neues Badge, keine neue Farbe und keine neue Spalte.
Ergaenze in groups-page.test.tsx zwei Faelle: eine Gruppe mit gesetztem internem Namen wird mit diesem angezeigt und traegt den AD-Namen als title; eine Gruppe ohne internen Namen wird mit ihrem gespeicherten Namen angezeigt. Beide Faelle nutzen die vorhandene Fetch- und Uebersetzungs-Stub-Mechanik der Datei.
cd apps/web && npx vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx"
cd apps/web && npx tsc --noEmit
<acceptance_criteria>
- grep -q "internalName: string | null" "apps/web/src/app/(portal)/admin/groups/page.tsx" trifft
- Die Namenszelle nutzt den Fallback und den Tooltip: grep -c "internalName ?? group.name" "apps/web/src/app/(portal)/admin/groups/page.tsx" ist >= 1 und grep -c "title={group.name}" "apps/web/src/app/(portal)/admin/groups/page.tsx" ist >= 1
- Keine Truthiness-Variante: grep -c "internalName ||" "apps/web/src/app/(portal)/admin/groups/page.tsx" ergibt 0
- Die Badge-Zelle ist unveraendert: grep -c "boundBadge" "apps/web/src/app/(portal)/admin/groups/page.tsx" ergibt weiterhin 1
- cd apps/web && npx vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx" ist gruen mit mindestens 2 neuen Faellen
</acceptance_criteria>
Die Gruppenliste zeigt den internen Namen, sobald einer gesetzt ist, und sonst den gespeicherten Namen; der AD-Name bleibt per Tooltip nachvollziehbar; die Bindungsspalte ist unangetastet.
Pruefe anschliessend das gesamte Web-Verzeichnis auf verbliebene Verweise. Findet sich noch einer, ist der Umbau aus Task 1 unvollstaendig — nicht den Schluessel wieder einfuegen, sondern die Referenz entfernen.
Ergaenze in groups-page.test.tsx die D-07-Absicherung: ein Fall, der den Dialog im Anlegen-Zustand oeffnet und danach prueft, dass kein Aufruf gegen den Verzeichnis-Endpunkt gegangen ist (Assertion ueber die vorhandene Fetch-Stub-Aufrufliste), sowie ein Fall, der den Dialog fuer eine importierte Gruppe oeffnet und prueft, dass das Namensfeld deaktiviert ist und das Feld fuer den internen Namen existiert. Passe bestehende Faelle an, die noch von der Radio-Auswahl ausgehen — dieser Task darf bestehende Tests aendern, nicht nur ergaenzen.
cd apps/web && npx vitest run
cd apps/web && npx tsc --noEmit
grep -rc '"ldapBind"' apps/web/src/messages/de.json apps/web/src/messages/en.json | grep -qv ':[1-9]'
<acceptance_criteria>
- grep -c '"ldapBind"' apps/web/src/messages/de.json ergibt 0
- grep -c '"ldapBind"' apps/web/src/messages/en.json ergibt 0
- Der Spaltenkopf-Schluessel bleibt erhalten: grep -c '"ldapBinding"' apps/web/src/messages/de.json ergibt 1 und dasselbe fuer en.json
- Kein Quelltext referenziert den entfernten Unterbaum mehr: grep -rc "ldapBind\." apps/web/src --include=*.tsx --include=*.ts | grep -qv ':[1-9]'
- Beide Sprachdateien tragen unter admin.groups denselben Schluesselsatz: node -e "const de=require('./apps/web/src/messages/de.json'),en=require('./apps/web/src/messages/en.json');const a=JSON.stringify(Object.keys(de.admin.groups).sort()),b=JSON.stringify(Object.keys(en.admin.groups).sort());if(a!==b)throw new Error(a+' vs '+b)"
- cd apps/web && npx vitest run ist vollstaendig gruen
</acceptance_criteria>
Beide Sprachdateien sind synchron und frei von verwaisten Schluesseln, kein Quelltext verweist mehr darauf, und der Verzicht auf jede AD-Auswahl im Dialog ist durch einen Test abgesichert statt nur behauptet.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
Browser → API (POST /groups, PATCH /groups/:id) |
Admin-Eingaben aus dem Dialog; der Dialog ist Bequemlichkeit, nicht Schutz |
| Gerenderter AD-Name/DN im Dialog und in der Liste | Werte stammen aus dem Verzeichnis und werden im Browser angezeigt |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-16-07 | Tampering | Gesperrtes Namensfeld | high | mitigate | Die Sperre ist im Browser sichtbar, aber durchgesetzt wird sie serverseitig durch die Ablehnung eines name fuer Gruppen mit gesetztem Identitaetsschluessel (Plan 16-02). Der Dialog sendet in diesem Zustand kein name; ein manipulierter Client kommt an der Backend-Invariante nicht vorbei |
| T-16-12 | Cross-Site Scripting | Anzeige von AD-Name und AD-DN | medium | mitigate | Beide Werte werden als React-Textknoten gerendert; in keiner der beiden Dateien wird eine Roh-HTML-Einbettung verwendet |
| T-16-13 | Elevation of Privilege | Zugriff auf die Gruppenverwaltung | high | accept | Die Rollenpruefung auf dieser Seite ist reine Anzeige; jede /groups-Route ist serverseitig durch den bestehenden RolesGuard geschuetzt (dokumentiert in groups/page.tsx, T-15-22). Dieser Plan aendert daran nichts |
| T-16-SC | Tampering | Paketinstallation | low | accept | Keine neuen Pakete in diesem Plan |
| </threat_model> |
Manuell nachzuholen (kein Browser-Tool in der Ausfuehrungssitzung vorgesehen): Dialog im Browser fuer alle drei Zustaende durchklicken — Anlegen mit Hinweis-Link, lokale Gruppe umbenennen, importierte Gruppe mit gesperrtem Namen und internem Namen speichern. Nach dem Speichern eines internen Namens erscheint dieser in der Gruppenliste, der AD-Name im Tooltip.
<success_criteria>
- Der Dialog bietet keinen Weg mehr, eine lokale Gruppe an eine AD-Gruppe zu binden (D-07)
- Das Namensfeld importierter Gruppen ist gesperrt und die Herkunft ist im Dialog sichtbar (D-03)
- Der interne Name ist im Dialog setz- und aenderbar und gewinnt in der Gruppenliste (D-04)
- Speicherfehler sind sichtbar und in der Ursache unterscheidbar
- Keine verwaisten Uebersetzungsschluessel in einer der beiden Sprachen </success_criteria>