docs(quick-260909-ab3): Plan fuer Matrix-Suche und Sync-Meldungen
WINDOWS #14 (Suche leert die jeweils andere Achse der Freigaben-Matrix) und WINDOWS #15 (roher Prisma-/englischer Techniktext im AD-Sync, vier Konten wegen geteilter E-Mail-Adresse nie importiert) als drei getrennte Tasks. Gesperrte Nutzerentscheidung vom 2026-09-09: kollidierende Konten werden angelegt, nur ohne Adresse. Dafuer wird User.email optional — Migration wird geschrieben, nicht ausgefuehrt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
+520
@@ -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)."
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Matrix-Suche filtert nur noch die getroffene Achse (WINDOWS #14)</name>
|
||||
<files>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</files>
|
||||
|
||||
<behavior>
|
||||
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.
|
||||
</behavior>
|
||||
|
||||
<action>
|
||||
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.`
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Kollidierende AD-Konten werden angelegt — ohne Adresse (WINDOWS #15, Verhalten)</name>
|
||||
<files>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</files>
|
||||
|
||||
<reversibility rating="costly">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.</reversibility>
|
||||
|
||||
<behavior>
|
||||
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(<geworfener Text>)`).
|
||||
|
||||
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.
|
||||
</behavior>
|
||||
|
||||
<action>
|
||||
**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.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Verstaendlicher deutscher Sync-Bericht und Konten ohne Adresse in der Oberflaeche (WINDOWS #15, Anzeige)</name>
|
||||
<files>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</files>
|
||||
|
||||
<behavior>
|
||||
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.
|
||||
</behavior>
|
||||
|
||||
<action>
|
||||
**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.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
</verify>
|
||||
|
||||
<done>
|
||||
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.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
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.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
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.
|
||||
</success_criteria>
|
||||
|
||||
<deployment_note>
|
||||
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.
|
||||
</deployment_note>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260909-ab3-matrix-suche-und-sync-meldungen-reparier/260909-ab3-SUMMARY.md` when done
|
||||
</output>
|
||||
Reference in New Issue
Block a user