Files
tessera-ctl/.planning/phases/16-ad-gruppen-synchronisation/16-04-PLAN.md
T

256 lines
25 KiB
Markdown

---
phase: 16-ad-gruppen-synchronisation
plan: 04
type: execute
wave: 3
depends_on: [16-01, 16-02]
files_modified:
- apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx
- apps/web/src/app/(portal)/admin/groups/page.tsx
- apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
autonomous: true
requirements: [PERM-02]
estimate:
tokens: 46000
raw_tokens: 46000
tasks: 3
confidence: low
must_haves:
truths:
- "Der Gruppen-Dialog bietet keinen Weg mehr, eine lokal angelegte Gruppe an eine AD-Gruppe zu binden: die Radio-Auswahl, der Discovery-Aufruf, der Bindung-entfernen-Link und der zweistufige Anlegen-dann-Binden-Ablauf sind vollstaendig entfernt (D-07)."
- "Der Anlegen-Zustand sendet genau einen POST /groups und keinen Folge-PATCH."
- "Im Bearbeiten-Zustand einer importierten Gruppe ist das Namensfeld deaktiviert, zeigt den AD-Namen, traegt darunter den Herkunftshinweis und darueber hinaus eine reine Anzeigezeile mit dem AD-DN (D-03, D-04)."
- "Im Bearbeiten-Zustand einer importierten Gruppe gibt es ein editierbares Feld 'Interner Name'; gespeichert wird in diesem Zustand ausschliesslich internalName, nie name (D-04)."
- "Die Gruppenliste zeigt in der Namensspalte internalName ?? name und traegt den AD-Namen als title-Attribut zur Nachvollziehbarkeit (D-04)."
- "Die sechs verwaisten i18n-Schluessel unter admin.groups fuer die entfallene AD-Bindungsauswahl sind aus de.json UND en.json entfernt; kein Quelltext referenziert sie noch."
- "Ein fehlgeschlagenes Speichern zeigt eine sichtbare Fehlerzeile im Dialog; der Dialog bleibt offen und die Eingaben bleiben erhalten. Meldet der Server eine Namenskollision, erscheint stattdessen der dafuer vorgesehene Text."
- "Der Bearbeiten-Dialog unterscheidet lokale und importierte Gruppen ausschliesslich am gesetzten ldapDn; eine Gruppe genau an dieser Grenze — ldapDn gesetzt, Identitaetsschluessel noch nicht nachgetragen — gilt als importiert und zeigt das gesperrte Namensfeld."
- "Der Anlegen-Zustand oeffnet mit leerem Namensfeld und ohne jedes AD-Steuerelement; ein leerer Name wird wie bisher als Pflichtfeld abgelehnt."
- "Ein interner Name aus reinen Leerzeichen wird wie ein leeres Feld behandelt und fuehrt serverseitig zu null, nicht zu einem leeren Anzeigenamen."
- "Der Dialog rendert seine drei Zustaende allein aus dem group-Prop und dessen ldapDn; es gibt keinen Effekt mehr, der beim Oeffnen ein AD-Verzeichnis nachlaedt."
- "UI E3/empty: Bestehender Empty-State der Gruppenliste unveraendert — dieser Plan aendert nur den Textwert der Namensspalte."
- "UI E3/loading: Bestehender Ladezustand der Gruppenliste unveraendert."
- "UI E3/error: Bestehendes Fehlerbanner der Gruppenliste unveraendert."
- "UI E3/populated: Die Namensspalte zeigt internalName ?? name; die AD-Bindungs-Spalte traegt weiterhin ihr Badge und ist nach D-07 der alleinige Marker importiert-vs-lokal."
- "UI E3/partial: Ist internalName einer importierten Gruppe nicht gesetzt, greift der Fallback auf name — es gibt keinen Zustand mit leerem Namen."
- "UI E3/overflow: Bestehendes Tabellen-Scrollverhalten unveraendert; dieser Plan fuegt keine Spalte hinzu."
- "UI E3/zero-one-many: Abgedeckt durch Empty-State und Listenrendering, beide unveraendert."
- "UI E4/empty: Der Anlegen-Zustand oeffnet mit leerem Pflichtfeld Name und dem Hinweisabsatz, dass AD-Gruppen im LDAP-Bereich importiert werden, samt Link dorthin."
- "UI E4/loading: Bestehender saving-Zustand — der Speichern-Button traegt den Ladetext und ist deaktiviert."
- "UI E4/error: Ein fehlgeschlagenes Speichern zeigt eine text-sm text-destructive Zeile im Dialog; der Dialog bleibt offen, Eingaben bleiben erhalten; bei Namenskollision greift die dafuer vorgesehene Copy."
- "UI E4/partial: Im Bearbeiten-Zustand einer importierten Gruppe ist der interne Name optional leer; das gesperrte Namensfeld zeigt weiterhin den AD-Namen und die AD-DN-Zeile bleibt sichtbar."
- statement: "UI E3/long-text: Ein langer interner oder AD-Name in der Namensspalte sprengt die Tabellenzeile nicht."
verification: backstop
- statement: "UI E4/long-text: Ein sehr langer AD-Name im gesperrten Eingabefeld sprengt den max-w-md-Modal-Container nicht."
verification: backstop
artifacts:
- "apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx — drei Zustaende ohne AD-Auswahl"
- "apps/web/src/app/(portal)/admin/groups/page.tsx — Group-Interface um internalName, Namenszelle mit Fallback und title"
- "apps/web/src/messages/de.json + en.json — neue Schluessel unter admin.groups, sechs verwaiste Schluessel entfernt"
- "apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx — Faelle fuer D-07, gesperrtes Namensfeld, internen Namen und Namensanzeige"
key_links:
- "GroupFormModal → PATCH /groups/:id mit ausschliesslich internalName fuer importierte Gruppen — sendet der Dialog weiterhin name, antwortet das Backend aus Plan 16-02 mit 400"
- "Group-Interface in groups/page.tsx → GroupFormModal: der Dialog importiert den Typ von dort, das Feld muss an der Quelle ergaenzt werden"
- "Entfernte i18n-Schluessel ↔ entfernter Dialogblock: bleibt ein Verweis stehen, faellt der Dialog zur Laufzeit auf einen fehlenden Schluessel"
prohibitions:
- "Die Oberflaeche darf keinen zweiten Weg anbieten, eine lokal angelegte Gruppe nachtraeglich an eine AD-Gruppe zu binden — weder als Formularfeld, noch als Link, noch als versteckter Request."
- "Das gesperrte Namensfeld darf nicht nur optisch deaktiviert sein: der Dialog darf in diesem Zustand kein name-Feld an den Server senden."
- "Der Dialog darf einen Fehlschlag beim Speichern nicht stumm verschlucken — der Benutzer muss sehen, dass nichts gespeichert wurde, und seine Eingaben behalten."
- "Die generische Admin-Oberflaeche darf keine kunden- oder firmenspezifischen Beispielnamen als Platzhalter im Feld fuer den internen Namen tragen."
---
<objective>
Der Gruppen-Dialog wird auf die Trennung aus D-07 zurueckgebaut: eine lokal angelegte Gruppe kann nicht mehr an eine AD-Gruppe gebunden werden, die in Plan 15-06 gebaute Radio-Auswahl verschwindet vollstaendig. An ihre Stelle treten die beiden Bausteine aus D-03 und D-04 — ein gesperrtes Namensfeld mit sichtbarer Herkunft fuer importierte Gruppen und ein frei editierbares Feld fuer den internen Namen, den der Sync nie anfasst. Die Gruppenliste zeigt ab hier den internen Namen, sobald einer gesetzt ist.
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.
</objective>
<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 |
<!-- planner-discipline-allow: "ldapBind" -->
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>
<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
</context>
<tasks>
<task type="auto">
<name>Task 1: GroupFormModal auf drei Zustaende zurueckbauen (D-03, D-04, D-07)</name>
<files>apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>
- `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`
</read_first>
<action>
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.
</action>
<verify>
<automated>cd apps/web &amp;&amp; npx tsc --noEmit</automated>
<automated>cd apps/web &amp;&amp; npx vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx"</automated>
<automated>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)}"</automated>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto">
<name>Task 2: Namensanzeige mit Fallback in der Gruppenliste (D-04)</name>
<files>apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx</files>
<read_first>
- `apps/web/src/app/(portal)/admin/groups/page.tsx` — das exportierte `Group`-Interface (Zeilen 12-21), die Namenszelle und die Badge-Zelle (Zeilen 179-190), das Fehlerbanner (135-140), der Empty-/Ladezustand (141-145)
- `apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx` — die vorhandene Uebersetzungs-Stub-Mechanik und der Fall `renders a populated table with name, AD-binding badge, default star and member count` (ab Zeile 163)
- `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Surface Contract 3 und die E3-Zeilen im Abschnitt UI Considerations
</read_first>
<action>
Erweitere das exportierte `Group`-Interface in `apps/web/src/app/(portal)/admin/groups/page.tsx` um `internalName: string | null` — dieselbe Nullbarkeit wie `ldapDn`, damit `GroupFormModal` den Typ ohne Zusatzarbeit mitnutzt. Das Backend liefert das Feld seit Plan 16-02 in `GET /groups`.
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.
</action>
<verify>
<automated>cd apps/web &amp;&amp; npx vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx"</automated>
<automated>cd apps/web &amp;&amp; npx tsc --noEmit</automated>
</verify>
<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>
<done>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.</done>
</task>
<task type="auto">
<name>Task 3: Verwaiste Uebersetzungsschluessel entfernen und D-07 im Test festnageln</name>
<files>apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx</files>
<read_first>
- `apps/web/src/messages/de.json` Zeilen 375-400 und `apps/web/src/messages/en.json` Zeilen 375-400 — der `admin.groups`-Block mit dem zu entfernenden Unterbaum
- `apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx` — die Fetch-Stub-Mechanik und die bestehenden Modal-Faelle
- `.planning/phases/16-ad-gruppen-synchronisation/16-UI-SPEC.md` — Abschnitt "Zu entfernende Copy" im Copywriting Contract
- `.planning/phases/16-ad-gruppen-synchronisation/16-RESEARCH.md` — Annahme A3 (beide Sprachdateien tragen denselben Unterbaum und muessen parallel bereinigt werden)
</read_first>
<action>
Entferne in **beiden** Sprachdateien den Unterbaum unter `admin.groups`, der die sechs Schluessel der entfallenen AD-Bindungsauswahl enthaelt (Hinweistext, Bindungsanzeige, Loesen-Link, Suchplatzhalter, Ladefehler, Leerergebnis). Er ist mit dem Umbau aus Task 1 vollstaendig verwaist. Alle uebrigen Schluessel unter `admin.groups` bleiben unangetastet — insbesondere der Spaltenkopf fuer die AD-Bindung, die beiden Badge-Texte und der Checkbox-Schluessel der Freigabe-Matrix. Der Unterschied zwischen dem Spaltenkopf-Schluessel und dem entfallenden Unterbaum ist ein einziges angehaengtes Wortende; loesche gezielt den Unterbaum, nicht den Praefix-Treffer.
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.
</action>
<verify>
<automated>cd apps/web &amp;&amp; npx vitest run</automated>
<automated>cd apps/web &amp;&amp; npx tsc --noEmit</automated>
<automated>grep -rc '"ldapBind"' apps/web/src/messages/de.json apps/web/src/messages/en.json | grep -qv ':[1-9]'</automated>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
1. `cd apps/web && npx vitest run` — vollstaendig gruen
2. `cd apps/web && npx tsc --noEmit` — fehlerfrei
3. Beide Sprachdateien tragen unter `admin.groups` denselben Schluesselsatz
4. Kein Quelltext im Web-Verzeichnis verweist noch auf den entfernten Unterbaum
**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.
</verification>
<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>
<output>
Create `.planning/phases/16-ad-gruppen-synchronisation/16-04-SUMMARY.md` when done
</output>