diff --git a/.planning/quick/260909-ab3-matrix-suche-und-sync-meldungen-reparier/260909-ab3-PLAN.md b/.planning/quick/260909-ab3-matrix-suche-und-sync-meldungen-reparier/260909-ab3-PLAN.md
new file mode 100644
index 0000000..c31c111
--- /dev/null
+++ b/.planning/quick/260909-ab3-matrix-suche-und-sync-meldungen-reparier/260909-ab3-PLAN.md
@@ -0,0 +1,520 @@
+---
+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.
+
+
+