--- quick_id: 260909-ab3 slug: matrix-suche-und-sync-meldungen-reparier date: 2026-09-09 status: planned relates_to: 15-modul-berechtigungen-gruppen-user-grants, 16-ad-gruppen-synchronisation windows_ref: 14, 15 severity: high phase: quick-260909-ab3 plan: 01 type: execute wave: 1 depends_on: [] files_modified: - apps/web/src/app/(portal)/admin/modules/grants/page.tsx - apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx - apps/api/prisma/schema.prisma - apps/api/prisma/migrations/20260909120000_user_email_optional/migration.sql - apps/api/src/ldap/ldap.service.ts - apps/api/src/ldap/ldap.service.spec.ts - apps/api/src/user/user.service.ts - apps/api/src/tenders/tender-digest.scheduler.ts - apps/api/src/tenders/tender-matching.service.ts - apps/web/src/app/(portal)/admin/ldap/page.tsx - apps/web/src/app/(portal)/admin/users/page.tsx - apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx - apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx - apps/web/src/messages/de.json - apps/web/src/messages/en.json - apps/web/src/messages/umlaut-dictionary.ts autonomous: true requirements: [PERM-02, PERM-03] estimate: tokens: 85000 raw_tokens: 85000 tasks: 3 confidence: low must_haves: truths: - "Freigaben-Matrix: ein Suchbegriff, der nur einen GRUPPENNAMEN trifft, laesst ALLE Modulzeilen stehen — die Kreuzung Modul x Gruppe bleibt anklickbar." - "Freigaben-Matrix: ein Suchbegriff, der nur einen MODULNAMEN trifft, laesst ALLE Gruppenspalten stehen — die Kreuzung bleibt anklickbar." - "Freigaben-Matrix: die Spaltensuche findet eine importierte Gruppe weiterhin sowohl unter ihrem internen Namen als auch unter ihrem AD-Namen (Bestandsverhalten aus WINDOWS #6c bleibt erhalten)." - "Freigaben-Matrix: ein Begriff, der weder ein Modul noch eine Gruppe trifft, zeigt eine sichtbare Meldung statt einer stumm leeren Tabelle." - "AD-Sync: ein Konto, dessen mail-Adresse bereits einem ANDEREN Konto gehoert, wird angelegt — ohne Adresse. Das zuerst angelegte Konto behaelt seine Adresse unveraendert." - "AD-Sync: keine Zeile des Sync-Berichts enthaelt rohen ORM-Ausnahmetext oder englischen Techniktext; die technischen Angaben landen ausschliesslich im Serverprotokoll." - "AD-Sync: Verzeichniseintraege ohne Anmeldenamen erscheinen als neutraler Hinweis (normaler Vorgang), nicht als Fehler." - "Ein Benutzer ohne E-Mail-Adresse bricht weder die Benutzerliste noch die Mitgliedersuche der Gruppenverwaltung." artifacts: - apps/api/prisma/migrations/20260909120000_user_email_optional/migration.sql - apps/web/src/app/(portal)/admin/modules/grants/page.tsx - apps/api/src/ldap/ldap.service.ts - apps/web/src/app/(portal)/admin/ldap/page.tsx key_links: - "upsertMappedUser() -> UserService.create({ email? }) -> Prisma User.email nullable — die Kette muss durchgaengig optional sein, sonst scheitert die Anlage weiterhin." - "LdapSyncResult (apps/api/src/ldap/ldap.service.ts) <-> interface SyncResult (apps/web/src/app/(portal)/admin/ldap/page.tsx) — neue Felder muessen auf beiden Seiten gleich heissen, sonst bleibt der Bericht leer." - "de.json <-> en.json Schluesselgleichheit — abgesichert durch den dritten Test in apps/web/src/messages/umlaut-guard.spec.ts (flacht BEIDE Dateien vollstaendig ab)." --- Zwei unabhaengige, am 2026-09-07/09 im Browser auf alpha gemessene Defekte reparieren. **Defekt A (WINDOWS #14)** — Die Suche in der Freigaben-Matrix filtert beide Achsen mit demselben Begriff und leert dadurch die jeweils andere: `page.tsx:137` filtert Module ueber `m.name`, `page.tsx:145` filtert Gruppen ueber `internalName ?? name`. Ein Begriff, der nur eine Achse trifft, entfernt die andere vollstaendig — es bleibt nie ein Kaestchen zum Klicken uebrig. Damit scheitert genau der Zweck der Suche. **Defekt B (WINDOWS #15)** — Der AD-Sync reicht rohe Techniktexte durch und laesst echte Konten still liegen. Vier Funktionskonten (CN=uvertrieb_ro, uvertrieb_rw, uvertrieb_ro_ss, usoftware_rw aus OU=CTL_PWS_Gruppen) tragen alle dieselbe Adresse `mbuntz@ctl.de` — die ihres Vorgesetzten. Sie scheitern deshalb an der Eindeutigkeitsregel auf `User.email` und werden NIE importiert; der Administrator liest nur die woertliche Prisma-Meldung. Sechs weitere Eintraege (Kontakte/Ressourcen ohne sAMAccountName) melden englisch `no username mapped (check sAMAccountName mapping)`, obwohl sie voellig korrekt uebersprungen werden. **Gesperrte Produktentscheidung des Nutzers vom 2026-09-09 (nicht verhandelbar):** solche Konten MUESSEN angelegt werden, nur eben OHNE E-Mail-Adresse. Wer eine Adresse zuerst belegt, behaelt sie; jedes spaetere Konto mit derselben Adresse entsteht ohne Adresse. Begruendung: die Anmeldung laeuft ueber den Benutzernamen, nicht ueber die Adresse — das Konto funktioniert also, lediglich Benachrichtigungen per E-Mail erreichen es nicht. Nichts geht mehr still verloren. Generisch umzusetzen: Tessera ist ein Mehrmandanten- Produkt, also keine firmenspezifischen Werte und keine Sonderfaelle fuer einzelne Konten im Code (Konvention "Keine Kunden-spezifischen Defaults"). Purpose: Die Freigaben-Matrix wird bedienbar, und der Sync verliert keine Konten mehr und spricht mit dem Administrator in verstaendlichem Deutsch. Output: Drei getrennte, jeweils fuer sich lauffaehige Commits — A, B-Verhalten, B-Anzeige. ## Gemessener Ausgangszustand (am Arbeitsbaum geprueft, 2026-09-09) | Beleg | Fundstelle | |-------|------------| | Beide Achsen unabhaengig gefiltert | `apps/web/src/app/(portal)/admin/modules/grants/page.tsx:133-147` | | `User.email` ist PFLICHTFELD (`String @unique`, kein `?`) | `apps/api/prisma/schema.prisma:32` | | Kollisionsquelle: Anlage schreibt die Adresse ungeprueft | `apps/api/src/ldap/ldap.service.ts:421` (Sync) und `:604` (Handimport) | | Englische Uebersprungen-Meldung | `apps/api/src/ldap/ldap.service.ts:862` (Sync) und `:576` (Handimport) | | Roher ORM-Text landet in `errors` | `apps/api/src/ldap/ldap.service.ts:885` (generischer Auffang je Eintrag) | | Bericht rendert `errors` unveraendert in Rot | `apps/web/src/app/(portal)/admin/ldap/page.tsx:1341-1350` | | Absturzrisiko bei leerer Adresse | `apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx:104` (`u.email.toLowerCase()`) | ## Zur Datenbank-Aenderung (bitte woertlich so weitergeben) `User.email` muss leer sein duerfen. Das ist eine Schema-Aenderung mit Migration. **Die Migration wird in diesem Plan NUR geschrieben, NICHT ausgefuehrt** — weder lokal noch auf dem Testserver. Das Ausrollen macht der Nutzer. Wichtige Richtigstellung zur Aufgabenbeschreibung: die API ruft `prisma migrate deploy` sehr wohl beim Start auf — im Startbefehl des Images (`apps/api/Dockerfile:38`). Die Migration greift also automatisch, sobald der Nutzer die API neu baut und neu startet. Solange sie NICHT angewandt ist, bleibt die Spalte ein Pflichtfeld: ein Konto mit kollidierender Adresse wird dann weiterhin nicht angelegt, scheitert aber sauber als gezaehlter Eintragsfehler im Bericht statt mit rohem Techniktext. Kein Absturz, keine stille Luecke. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.planning/STATE.md @CLAUDE.md Bestandscode, der beim Umsetzen gelesen werden muss: @apps/web/src/app/(portal)/admin/modules/grants/page.tsx @apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx @apps/api/src/ldap/ldap.service.ts @apps/api/src/ldap/ldap.service.spec.ts @apps/web/src/app/(portal)/admin/ldap/page.tsx @apps/web/src/messages/umlaut-guard.spec.ts Verbindliche Konventionen aus dem Bestand: - Strukturierte Rueckgabe statt uebersetzter Backend-Prosa: `LdapGroupImportResult.nameCollisions` ist genau dafuer eingefuehrt worden (`ldap.service.ts:74-86`) — Backend liefert Daten, das Frontend formuliert den Satz aus `de.json`/`en.json`. - Sprachschluessel-Gate: der dritte Test in `apps/web/src/messages/umlaut-guard.spec.ts` flacht de.json UND en.json vollstaendig ab und vergleicht die Schluesselmengen. Ein Schluessel nur in einer Datei faellt sofort auf. - Umlaut-Gate: jedes NEUE deutsche Wort in `de.json`, das die Folge `ae`, `oe`, `ue` oder `ss` enthaelt, muss in `UMLAUT_ALLOWLIST` (`apps/web/src/messages/umlaut-dictionary.ts`) stehen, sonst wird der Test rot. Betroffen von den hier geplanten Texten waeren z. B. `Adressen` oder `Ressourcen` (`Adresse`, `muss`, `Passwort` stehen bereits drin). Die Fehlermeldung des Tests nennt das fehlende Wort woertlich. Task 1: Matrix-Suche filtert nur noch die getroffene Achse (WINDOWS #14) apps/web/src/app/(portal)/admin/modules/grants/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json Verbindliche Semantik (in dieser Reihenfolge auszuwerten), `moduleHits` = Module deren `name` passt, `groupHits` = Gruppen deren `internalName ?? name` ODER `name` passt: 1. Leeres Suchfeld -> alle Module, alle Gruppen. (Bestandsverhalten) 2. `moduleHits` nicht leer, `groupHits` leer -> nur `moduleHits` als Zeilen, ALLE Gruppen als Spalten. 3. `groupHits` nicht leer, `moduleHits` leer -> ALLE Module als Zeilen, nur `groupHits` als Spalten. 4. Beide nicht leer -> beide Achsen gefiltert. Das leert keine Achse, weil beide Mengen Treffer enthalten; die Kreuzung bleibt klickbar. 5. Beide leer (Begriff trifft nichts) -> Tabelle gar nicht rendern, stattdessen eine sichtbare Meldung. Neue Testfaelle in `grants-matrix.test.tsx` (im bestehenden `describe('AdminModuleGrantsPage (Permission-Matrix)')`): - Test 1: Suche 'Buchhaltung' -> 'Ausschreibungs-Radar' UND 'DKV Flotte' sind sichtbar, 'Alle Benutzer' ist verschwunden, `screen.getAllByRole('checkbox')` ist nicht leer. - Test 2: Suche 'Flotte' -> 'Alle Benutzer' UND 'Buchhaltung' sind sichtbar, 'Ausschreibungs-Radar' ist verschwunden. - Test 3: Suche 'Vertrieb' (interner Name) und danach — nach `clear()` — 'Claude_VT' (AD-Name) -> in beiden Faellen ist die Spaltenueberschrift 'Vertrieb' sichtbar UND beide Modulnamen sind sichtbar. Regressionsschutz fuer WINDOWS #6c. - Test 4: Suche 'zzz' -> die neue Meldung ist sichtbar und `screen.queryAllByRole('checkbox')` hat Laenge 0. Vor der Umsetzung gegen den unveraenderten Bestand laufen lassen und die Rotfaerbung protokollieren. Erwartete Ursachen: Test 1 scheitert, weil beide Modulnamen fehlen; Test 2, weil beide Gruppenueberschriften fehlen; Test 3, weil die Modulnamen fehlen; Test 4, weil der Meldungstext nirgends existiert. Sind die Ursachen andere, erst klaeren, dann umsetzen. Zuerst die Testdatei erweitern, dann die Seite anpassen. In `grants-matrix.test.tsx`: `mockMatrix.groups` um einen dritten Eintrag ergaenzen, der eine importierte AD-Gruppe abbildet — `{ id: 'g3', name: 'Claude_VT', internalName: 'Vertrieb' }`. Bestehende Tests duerfen dadurch nicht rot werden (sie pruefen namentlich genannte Texte, eine zusaetzliche Spalte stoert nicht). Den lokalen `messages`-Mock im Namensraum `adminModules.grants` um den neuen Schluessel ergaenzen, damit der Meldungstext im Test aufgeloest wird. Danach die vier oben beschriebenen Testfaelle ergaenzen. In `page.tsx`: `matches` (Zeile 134) unveraendert lassen. `filteredModules` (Zeile 136-140) und `filteredGroups` (Zeile 141-147) durch einen gemeinsamen `useMemo` ueber `[modules, groups, searchLower]` ersetzen, der `moduleHits`, `groupHits`, `filteredModules`, `filteredGroups` und ein `noMatch`-Flag nach der Semantik oben liefert. Die vorhandene Erklaerung zur doppelten Gruppen-Namenspruefung (interner Name UND AD-Name, D-04) bleibt als Kommentar erhalten — dieses Verhalten ist gemessen richtig und darf nicht wegfallen. `groupedModules` (Zeile 149-160) bleibt unveraendert und liest weiter `filteredModules`. Im Renderteil: die Tabelle ab Zeile 205 nur noch rendern, wenn `noMatch` falsch ist. Bei `noMatch` stattdessen einen Absatz mit `t('noSearchResults', { search })` ausgeben, gestaltet wie der vorhandene Leerzustand weiter oben (`rounded-md border border-border bg-card p-6 text-center`, Text in `text-sm text-muted-foreground`). Das Suchfeld selbst bleibt immer sichtbar, sonst kann der Nutzer seinen Begriff nicht mehr korrigieren. Sprachdateien: `adminModules.grants.noSearchResults` in BEIDE Dateien. de: `Kein Treffer fuer "{search}" — weder bei den Modulen noch bei den Gruppen.` — dabei die typografischen Anfuehrungszeichen und das korrekte Wort mit u-Umlaut verwenden, keine Ersatzschreibung (das Umlaut-Gate prueft genau das). en: `No match for "{search}" — neither in the modules nor in the groups.` cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx" "src/messages/umlaut-guard.spec.ts" && pnpm --filter @tessera/web type-check Die vier neuen Testfaelle sind gruen, die acht bestehenden Testfaelle der Datei ebenfalls. Das Sprachschluessel-Gate (umlaut-guard.spec.ts) ist gruen, also existiert der neue Schluessel in de.json UND en.json. `pnpm --filter @tessera/web type-check` ist sauber. Eigener Commit, der ausschliesslich Defekt A enthaelt. Task 2: Kollidierende AD-Konten werden angelegt — ohne Adresse (WINDOWS #15, Verhalten) apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260909120000_user_email_optional/migration.sql, apps/api/src/ldap/ldap.service.ts, apps/api/src/ldap/ldap.service.spec.ts, apps/api/src/user/user.service.ts, apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-matching.service.ts Das Aufheben der Pflicht auf `User.email` ist rueckgaengig zu machen, solange keine Zeile leer ist; sobald der erste Sync Konten ohne Adresse angelegt hat, verlangt die Rueckkehr zum Pflichtfeld ein Nachtragen oder Loeschen dieser Zeilen. Die Produktentscheidung ist vom Nutzer am 2026-09-09 gesperrt, daher kein Halt. Neue Testfaelle in `ldap.service.spec.ts` — Konventionen des Bestands uebernehmen (gemocktes `ldapts`, `prisma` als Objekt-Mock, `userService.create` als `vi.fn()`). Der neue Kollisionscheck laeuft ueber `prisma.user.findUnique`; das ist im Bestand noch nicht gemockt und muss dem Prisma-Mock hinzugefuegt werden (Vorgabe `null`). - Test 1 "legt ein Konto mit belegter Adresse trotzdem an, nur ohne Adresse": zwei Eintraege mit derselben `mail`, `findUnique` liefert beim zweiten den bereits angelegten Fremdbenutzer. Erwartung: `userService.create` wurde ZWEIMAL gerufen; der erste Aufruf traegt die Adresse, der zweite traegt keine (`email` ist `undefined`/nicht gesetzt). `result.created === 2`. - Test 2 "meldet die Kollision strukturiert und nicht als Fehler": `result.emailConflicts` enthaelt genau einen Eintrag mit Kontoname und der belegten Adresse; `result.errors` ist leer. - Test 3 "nimmt einem bestehenden Konto seine Adresse nicht weg": ein bereits vorhandener Benutzer (Treffer ueber `findFirst`) bekommt eine Adresse zugeordnet, die einem DRITTEN Benutzer gehoert. Erwartung: `prisma.user.update` wird OHNE `email` im `data`-Objekt gerufen; die Kollision steht in `emailConflicts`. - Test 4 "meldet Eintraege ohne Anmeldenamen getrennt und nicht als Fehler": ein Eintrag ohne `sAMAccountName`. Erwartung: seine Kennung steht in `result.skippedNoLogin`, `result.errors` ist leer. - Test 5 "reicht keinen rohen Datenbanktext an den Bericht durch": `userService.create` wirft einmal einen Fehler, dessen `message` den Wortlaut einer ORM-Ausnahme traegt. Erwartung: die Kennung des Eintrags steht in `result.entryFailures`; KEIN Element von `errors`, `entryFailures`, `skippedNoLogin` oder der Kollisionsliste enthaelt den geworfenen Ausnahmetext (Vergleich per `expect(JSON.stringify(result)).not.toContain()`). Vor der Umsetzung gegen den unveraenderten Bestand laufen lassen. Erwartete Rotfaerbung: Test 1 scheitert, weil der zweite `create`-Aufruf die Adresse mitschickt; Test 2/4 scheitern, weil die Felder nicht existieren; Test 3, weil `update` die Adresse mitschreibt; Test 5, weil der Ausnahmetext woertlich in `errors` steht. Andere Ursachen erst klaeren. **Schritt 1 — Schema und Migration (nur schreiben, nicht ausfuehren).** In `apps/api/prisma/schema.prisma:32` das Feld auf optional stellen (`String? @unique`). Neues Verzeichnis `apps/api/prisma/migrations/20260909120000_user_email_optional/` mit `migration.sql`, das die Pflicht auf der Spalte aufhebt (`ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL;`). Den vorhandenen Eindeutigkeitsindex NICHT anfassen: PostgreSQL behandelt leere Werte in einer Eindeutigkeitsregel als jeweils verschieden, mehrere Konten ohne Adresse sind also erlaubt. Kopfkommentar auf Deutsch im Stil der Bestandsmigrationen (siehe `20260812110000_tender_rss_feed_owner/migration.sql`): Anlass, gesperrte Nutzerentscheidung vom 2026-09-09, und der Hinweis, dass Bestandszeilen unangetastet bleiben. Danach den Client neu erzeugen, damit die Typen die Optionalitaet kennen — das ist reine Codegenerierung, kein Ausrollen. **Schritt 2 — geteilter Kollisionsentscheider in `ldap.service.ts`.** Eine private Hilfsmethode ergaenzen, die zu einer gewuenschten Adresse und der Kennung eines eventuell schon vorhandenen eigenen Datensatzes entscheidet, ob die Adresse geschrieben werden darf. Sie fragt `this.prisma.user.findUnique({ where: { email } })` und gibt die Adresse nur zurueck, wenn niemand sie haelt oder der Halter derselbe Datensatz ist; andernfalls gibt sie keine Adresse und einen Kollisionsvermerk aus Kontoname und Adresse zurueck. Bewusst `findUnique` und nicht `findFirst`: die Spalte ist eindeutig, und die bestehende `findFirst`-Nutzung in `upsertMappedUser` bleibt dadurch eindeutig unterscheidbar — auch im Test. In `upsertMappedUser` (ab Zeile 389) den Rueckgabetyp von `'created' | 'updated'` auf ein Objekt erweitern, das den Status UND einen optionalen Kollisionsvermerk traegt. Im Aktualisierungszweig (Zeile 410) die Adresse nur uebernehmen, wenn der Entscheider sie freigibt — eine fremde Adresse wird niemals umgehaengt, der bestehende Wert bleibt stehen. Im Anlagezweig (Zeile 419-427) die Adresse nur setzen, wenn sie freigegeben ist; ist sie belegt, das Konto ohne Adresse anlegen. Der vorhandene Ersatzwert `${username}@ldap.local` fuer Eintraege OHNE `mail`-Attribut bleibt unveraendert — er ist nicht Teil dieses Defekts und wird hier ausdruecklich nicht angefasst. Denselben Entscheider auch in `importUsersByDn` an der Anlagestelle (Zeile 602-609) verwenden, damit der Handimport nicht als zweiter Weg mit rohem Datenbanktext bestehen bleibt. `LdapUserImportResult` bekommt dabei KEIN neues Feld: das Konto entsteht, wird als `created` gezaehlt, und die Anzeige dieses Wegs bleibt unveraendert. **Schritt 3 — strukturierter Sync-Bericht.** `LdapSyncResult` (ab Zeile 11) um drei Felder ergaenzen: eine Liste der Konten, die ohne Adresse angelegt oder aktualisiert wurden (je Eintrag Kontoname und belegte Adresse); eine Liste der Kennungen, die mangels Anmeldenamen uebersprungen wurden; eine Liste der Kennungen, bei denen ein unerwarteter Fehler auftrat. Die Initialisierung bei Zeile 768 entsprechend ergaenzen — es ist die einzige Stelle im Code, an der ein `LdapSyncResult` entsteht. In `syncUsersForTenant`: die Meldung bei Zeile 861-863 durch einen Eintrag in der Uebersprungen-Liste ersetzen; den generischen Auffang bei Zeile 880-886 so umbauen, dass nur noch die Kennung des Eintrags in die Fehlerliste wandert, waehrend der technische Wortlaut ueber `this.logger.error` ins Serverprotokoll geht und den Bericht nie erreicht. Den Kollisionsvermerk aus `upsertMappedUser` in die Kollisionsliste uebernehmen. Nicht anfassen: `result.errors` traegt weiterhin die Lauffehler (`Sync failed: ...`) und die Gruppenmeldungen aus `syncBoundGroupsForTenant`/`syncGroupMembershipsForTenant`. Diese sind bereits deutsche Prosa und nicht Gegenstand dieses Defekts. **Schritt 4 — Folgeanpassungen der Typkette.** `UserService.create` (`user.service.ts:49-58`) nimmt die Adresse ab jetzt optional entgegen. Die Typpruefung wird nach der Neuerzeugung des Clients an zwei weiteren Stellen rot, weil eine Adresse jetzt leer sein kann; beide sind Mailversand und beide muessen den Empfaenger ueberspringen statt zu senden: `tender-digest.scheduler.ts:140` und `tender-matching.service.ts:131` — vor dem Aufruf abbrechen, wenn der Benutzer keine Adresse hat, und mit `continue` zum naechsten weitergehen (die umgebenden Schleifen tun das bei fehlendem Benutzer bereits genauso). Das ist genau das zugesagte Verhalten: das Konto funktioniert, nur Benachrichtigungen erreichen es nicht. Tauchen weitere Fundstellen auf, nach demselben Muster behandeln und im SUMMARY vermerken. cd /home/vicolab/projects/tessera-ctl/apps/api && DATABASE_URL="postgresql://u:p@localhost:5432/db" npx prisma validate && DATABASE_URL="postgresql://u:p@localhost:5432/db" npx prisma generate && cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/ldap/ldap.service.spec.ts && pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/api type-check Die fuenf neuen Testfaelle sind gruen, die 62 bestehenden Testfaelle der Datei ebenfalls, und die vollstaendige API-Testsuite laeuft durch. `prisma validate` bestaetigt das Schema, `pnpm --filter @tessera/api type-check` ist sauber. Die Migrationsdatei existiert und wurde NICHT ausgefuehrt. Eigener Commit, getrennt von Task 1 und Task 3. Task 3: Verstaendlicher deutscher Sync-Bericht und Konten ohne Adresse in der Oberflaeche (WINDOWS #15, Anzeige) apps/web/src/app/(portal)/admin/ldap/page.tsx, apps/web/src/app/(portal)/admin/users/page.tsx, apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts Neuer Testfall in `groups-page.test.tsx`, im bestehenden `describe('GroupMembersModal (via AdminGroupsPage)')`: "Die Benutzersuche stuerzt bei einem Konto ohne Adresse nicht ab". `buildRouterFetchMock` so erweitern, dass der `/users`-Zweig (heute `[]`) einen Benutzer ohne Adresse liefert (`{ id: 'u12', username: 'funktionskonto', displayName: null, email: null }`). Im Test die Mitgliederansicht der Gruppe 'Buchhaltung' oeffnen, in das Suchfeld mit dem Platzhalter 'Benutzer suchen...' den Begriff 'funktion' tippen und erwarten, dass 'funktionskonto' in der Ergebnisliste erscheint. Vor der Umsetzung gegen den unveraenderten Bestand laufen lassen. Erwartete Rotfaerbung: `GroupMembersModal.tsx:104` ruft `.toLowerCase()` auf einem leeren Wert auf und die Darstellung bricht ab. Ist die Ursache eine andere, erst klaeren. **Sync-Bericht.** In `apps/web/src/app/(portal)/admin/ldap/page.tsx` das `interface SyncResult` (Zeile 62-76) um die drei in Task 2 ergaenzten Felder erweitern — gleiche Namen, gleiche Formen wie im Backend, sonst bleibt der Bericht leer. Im Anzeigeblock (Zeile 1312-1351) unter den drei Zahlenzeilen drei getrennte Abschnitte ergaenzen, jeweils nur wenn die zugehoerige Liste nicht leer ist, und in dieser Reihenfolge: 1. Konten ohne Adresse — braucht Aufmerksamkeit, aber es ist kein Fehler. Farbe wie die vorhandene Bernstein-Zeile `defaultMarkerMoved` (`text-amber-700 dark:text-amber-400`). Ueberschriftssatz aus dem Sprachkatalog, darunter je Eintrag eine Zeile mit Kontoname und belegter Adresse. 2. Uebersprungene Eintraege ohne Anmeldenamen — neutraler Hinweis, Farbe `text-muted-foreground`, ausdruecklich NICHT `text-destructive`. Ueberschriftssatz aus dem Katalog, darunter die Kennungen als Datenzeilen. 3. Unerwartete Fehler je Eintrag — `text-destructive`, Ueberschriftssatz aus dem Katalog mit dem Hinweis, dass die technischen Angaben im Serverprotokoll stehen, darunter die Kennungen. Der bestehende `errors`-Block bleibt unveraendert bestehen: er traegt weiterhin Lauf- und Gruppenfehler. **Sprachkatalog.** Unter `admin.ldap.sync` in BEIDE Dateien drei Ueberschriftsschluessel und einen Zeilenschluessel fuer die Kollision ergaenzen. Inhaltlich verbindlich (Wortlaut darf gefeilt werden, Aussage nicht): - Kollision, Ueberschrift: diese Konten wurden ohne E-Mail-Adresse angelegt, weil die Adresse bereits zu einem anderen Konto gehoert; Anmeldung und Zugriff funktionieren, nur Benachrichtigungen per E-Mail erreichen sie nicht. - Kollision, Zeile: Kontoname und die bereits vergebene Adresse, mit Platzhaltern `{account}` und `{email}`. - Uebersprungen, Ueberschrift: ohne Anmeldenamen uebersprungen — normal fuer Kontakte und Verteiler im Verzeichnis. - Unerwarteter Fehler, Ueberschrift: bei diesen Eintraegen trat ein unerwarteter Fehler auf, die technischen Angaben stehen im Protokoll des Servers. Kein Fachbegriff, kein Produktname einer Bibliothek, keine englische Wendung im deutschen Text. Jedes neue deutsche Wort mit der Folge `ae`/`oe`/`ue`/`ss`, das noch nicht in `UMLAUT_ALLOWLIST` steht, dort ergaenzen — die Fehlermeldung des Gates nennt das Wort woertlich, und `Adresse`, `muss`, `Passwort` stehen bereits drin. **Konten ohne Adresse in der Oberflaeche.** In `GroupMembersModal.tsx` das Feld im `interface TenantUser` als moeglicherweise leer deklarieren und den Suchvergleich bei Zeile 104 gegen einen leeren Wert absichern — nach demselben Muster, das die Zeile darueber fuer `displayName` bereits verwendet. Die Anzeige bei Zeile 233 bleibt wie sie ist und stellt bei leerem Wert nichts dar. In `apps/web/src/app/(portal)/admin/users/page.tsx` das Feld im `interface User` (Zeile 13) ebenfalls als moeglicherweise leer deklarieren; die Uebernahme in das Formular (Zeile 96) faellt dann auf eine leere Zeichenkette zurueck, damit das Eingabefeld ein kontrolliertes Feld bleibt, und die Tabellenzelle (Zeile 225) zeigt bei leerem Wert einen Gedankenstrich statt einer leeren Zelle. `UserFormData.email` bleibt eine Pflichtangabe: die Anlage von Hand verlangt weiterhin eine Adresse. cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/groups/groups-page.test.tsx" "src/messages/umlaut-guard.spec.ts" && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/web type-check Der neue Testfall ist gruen, die uebrigen Testfaelle von `groups-page.test.tsx` ebenfalls, und die vollstaendige Web-Testsuite laeuft durch. Das Sprachschluessel-Gate ist gruen, also stehen alle neuen Schluessel in de.json UND en.json, und das Umlaut-Gate meldet kein ungepruefte neues Wort. `pnpm --filter @tessera/web type-check` ist sauber. Eigener Commit, getrennt von Task 1 und Task 2. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Active Directory -> Tessera-Sync | Fremdgepflegte Verzeichnisdaten (Anzeigename, mail, sAMAccountName) fliessen ungeprueft in eigene Datensaetze. | | API -> Administrations-Oberflaeche | Der Sync-Bericht transportiert Servertexte in den Browser eines Administrators. | | Nutzereingabe -> Freigaben-Matrix | Der Suchbegriff steuert, welche Kaestchen sichtbar und damit klickbar sind. | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-Q3-01 | Spoofing | `upsertMappedUser` Aktualisierungszweig (`ldap.service.ts:410`) | high | mitigate | Eine Adresse wird NIE von einem Datensatz auf einen anderen umgehaengt. Der Entscheider vergleicht die Kennung des Halters mit der des eigenen Datensatzes; passt sie nicht, bleibt die Adresse beim bisherigen Konto. Sonst koennte ein Verzeichniseintrag die Adresse eines echten Menschen an sich ziehen und darueber dessen Passwort-Zuruecksetzung empfangen. Abgesichert durch Test 3 aus Task 2. | | T-Q3-02 | Information Disclosure | Sync-Bericht (`ldap.service.ts:885` -> `admin/ldap/page.tsx:1341`) | medium | mitigate | Roher Ausnahmetext der Datenbankschicht nennt Tabellen-, Spalten- und Aufrufnamen und wird einem Administrator im Klartext gezeigt. Ab jetzt wandert nur die Kennung des Eintrags in den Bericht, der technische Wortlaut ausschliesslich ins Serverprotokoll. Abgesichert durch Test 5 aus Task 2. | | T-Q3-03 | Information Disclosure | Auflistung der Verzeichniskennungen im Bericht | low | accept | Die Seite ist bereits auf ADMIN/SUPER_ADMIN beschraenkt, und wer den Sync ausloest, kennt das Verzeichnis ohnehin. Kein zusaetzlicher Schutz. | | T-Q3-04 | Spoofing | Passwort-Zuruecksetzung bei leerer Adresse (`auth.service.ts:143-146`) | medium | mitigate | Die Suche laeuft ueber eine angefragte Zeichenkette; ein leerer Wert in der Spalte wird davon nie getroffen, ein Konto ohne Adresse ist also ueber diesen Weg nicht ansprechbar — genau die zugesagte Wirkung. Keine Codeaenderung noetig, aber vor Abschluss zu bestaetigen. | | T-Q3-05 | Denial of Service | Zusaetzliche Adressabfrage je Verzeichniseintrag | low | accept | Eine zusaetzliche indizierte Punktabfrage je Eintrag; bei den gemessenen 405 Konten vernachlaessigbar, und der Sync laeuft ohnehin nicht im Anfragepfad eines Nutzers. | | T-Q3-06 | Tampering | Anzeige der Matrix nach Suche | low | accept | Die Suche veraendert ausschliesslich die Sichtbarkeit; jeder Umschaltvorgang laeuft weiterhin ueber den bestehenden, serverseitig geprueften Endpunkt. Eine gefilterte Ansicht kann keine Freigabe setzen, die der Server nicht erlaubt. | | T-Q3-SC | Tampering | Paketinstallationen | low | accept | Dieser Plan installiert kein einziges Paket (npm/pip/cargo). Das Legitimitaets-Gate greift daher nicht; entsteht beim Umsetzen doch ein Installationsbedarf, ist die Arbeit zu stoppen und der Bedarf vorzulegen. | Nach allen drei Tasks, vom Wurzelverzeichnis aus: ``` pnpm --filter @tessera/api exec vitest run pnpm --filter @tessera/api type-check pnpm --filter @tessera/web exec vitest run pnpm --filter @tessera/web type-check ``` Alle vier muessen sauber durchlaufen. Der Ausgangsstand vor der Arbeit ist gemessen: `ldap.service.spec.ts` 62/62 gruen, `grants-matrix.test.tsx` 8/8 gruen, API-Typpruefung sauber. **Vom Nutzer im Browser nachzuholen (nicht durch den Ausfuehrenden, siehe Konvention "Kein Docker-Deploy auf Testserver"), nach seinem naechsten Neubau von API und Web:** 1. Freigaben-Matrix oeffnen und nach `Claude_VT` bzw. `Vertrieb` suchen: die Gruppenspalte bleibt stehen UND alle Modulzeilen bleiben stehen, es gibt anklickbare Kaestchen. 2. Dieselbe Matrix, Suche nach `Cert`: die Modulzeile bleibt stehen UND alle Gruppenspalten bleiben stehen. 3. Dieselbe Matrix, Begriff ohne Treffer: eine verstaendliche Meldung statt einer leeren Tabelle. 4. AD-Sync ausloesen: die vier Konten `uvertrieb_ro`, `uvertrieb_rw`, `uvertrieb_ro_ss` und `usoftware_rw` erscheinen ab jetzt in der Benutzerliste — ohne Adresse. Das Konto, das `mbuntz@ctl.de` bereits haelt, behaelt die Adresse unveraendert. 5. Derselbe Bericht: keine Zeile enthaelt englischen Techniktext oder Datenbankwortlaut; die sechs Kontakte ohne Anmeldenamen stehen als neutraler Hinweis, nicht in Rot. 1. Suche nach einem Gruppennamen laesst alle Modulzeilen stehen; Suche nach einem Modulnamen laesst alle Gruppenspalten stehen; in beiden Faellen bleibt mindestens ein Kaestchen klickbar. Belegt durch zwei Testfaelle, die gegen den heutigen Stand rot sind. 2. Die Spaltensuche findet eine importierte Gruppe weiterhin unter internem UND AD-Namen. 3. Ein Begriff ohne Treffer erzeugt eine sichtbare Meldung, keine stumm leere Tabelle. 4. Ein Verzeichniskonto mit bereits belegter Adresse wird angelegt — ohne Adresse — und der bisherige Halter der Adresse behaelt sie. 5. Kein Bestandteil des Sync-Berichts traegt rohen Datenbank-Ausnahmetext oder englischen Techniktext; die technischen Angaben stehen nur im Serverprotokoll. 6. Uebersprungene Eintraege ohne Anmeldenamen erscheinen als neutraler Hinweis, getrennt von echten Fehlern. 7. Ein Konto ohne Adresse bricht weder die Benutzerliste noch die Mitgliedersuche. 8. Alle neuen Anzeigetexte stehen in de.json UND en.json; beide Sprachgates sind gruen. 9. Beide Testsuiten und beide Typpruefungen sind vollstaendig gruen. 10. Drei getrennte Commits: Defekt A, Verhalten von Defekt B, Anzeige von Defekt B. Dieser Plan rollt NICHTS aus: kein `docker compose build`, kein `up`, kein `restart`, kein `prisma migrate deploy`, keine Aktion auf dem Testserver. Die Migration wird ausschliesslich als Datei geschrieben. Fuer den Nutzer: die Migration steckt im API-Image und wird vom Startbefehl des Containers angewandt (`apps/api/Dockerfile:38` ruft `prisma migrate deploy` vor dem Start auf). Ein Neubau und Neustart der API genuegt also. Solange das nicht geschehen ist, bleibt die Spalte ein Pflichtfeld und die vier Konten werden weiterhin nicht angelegt — dann aber mit einer verstaendlichen Zeile im Bericht statt mit rohem Techniktext. Create `.planning/quick/260909-ab3-matrix-suche-und-sync-meldungen-reparier/260909-ab3-SUMMARY.md` when done