From 21670462dd0d2cf069a70e05f1555c9942e77db5 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 9 Sep 2026 07:54:32 +0200 Subject: [PATCH] feat(quick-260909-ab3): verstaendlicher Sync-Bericht, Konten ohne Adresse in der UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin/ldap/page.tsx: SyncResult um emailConflicts/skippedNoLogin/ entryFailures erweitert; drei neue Berichtsabschnitte (Bernstein fuer Kollisionen, neutral fuer fehlenden Anmeldenamen, destruktiv fuer unerwartete Fehler) — kein englischer Techniktext, kein roher Datenbank-Wortlaut mehr im Bericht - GroupMembersModal.tsx: TenantUser.email optional, Suchvergleich gegen leere Adresse abgesichert (WINDOWS #15) — die Mitgliedersuche stuerzt nicht mehr ab, sobald ein Konto ohne Adresse existiert - users/page.tsx: User.email optional, Formular faellt auf leere Zeichenkette zurueck, Tabellenzelle zeigt einen Gedankenstrich; UserFormData.email bleibt bei Handanlage Pflicht - Neue Sprachschluessel unter admin.ldap.sync in de.json/en.json, Umlaut- und Sprachschluessel-Gate bestaetigt gruen (kein neues Allowlist-Wort noetig) - Neuer Testfall in groups-page.test.tsx vorab gegen den unveraenderten Bestand rot gelaufen; Abweichung von der Plan-Fixture dokumentiert (Kurzfassung: ein einzelnes Konto mit passendem Benutzernamen loest die Kollision wegen OR-Kurzschlussauswertung nie aus, ein zweites, nicht-treffendes Konto ohne Adresse schon — Vollfassung im SUMMARY) - 651/651 API- und 233/233 Web-Tests gruen, beide Typpruefungen sauber Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU --- .../groups/components/GroupMembersModal.tsx | 6 ++- .../admin/groups/groups-page.test.tsx | 36 ++++++++++++++- apps/web/src/app/(portal)/admin/ldap/page.tsx | 46 +++++++++++++++++++ .../web/src/app/(portal)/admin/users/page.tsx | 9 ++-- apps/web/src/messages/de.json | 4 ++ apps/web/src/messages/en.json | 4 ++ 6 files changed, 99 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx b/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx index 9de2e8e..b98ccb7 100644 --- a/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx +++ b/apps/web/src/app/(portal)/admin/groups/components/GroupMembersModal.tsx @@ -26,7 +26,9 @@ interface TenantUser { id: string; username: string; displayName: string | null; - email: string; + // WINDOWS #15: ein Konto ohne Adresse (kollidierte AD-Adresse) ist + // moeglich, seit User.email optional ist. + email: string | null; } interface GroupMembersModalProps { @@ -101,7 +103,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod (u) => u.username.toLowerCase().includes(q) || (u.displayName ?? '').toLowerCase().includes(q) || - u.email.toLowerCase().includes(q), + (u.email ?? '').toLowerCase().includes(q), ); }, [allUsers, userSearchQuery]); diff --git a/apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx b/apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx index 2e78a3b..2e5bb38 100644 --- a/apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx +++ b/apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx @@ -402,6 +402,7 @@ const mockMembers: GroupMembership[] = [ function buildRouterFetchMock(opts: { impact?: { memberCount: number; grantCount: number }; deleteOk?: boolean; + users?: unknown[]; }) { return vi.fn((url: string, init?: RequestInit) => { if (typeof url !== 'string') { @@ -414,7 +415,7 @@ function buildRouterFetchMock(opts: { return Promise.resolve({ ok: true, json: () => Promise.resolve(mockMembers) }); } if (url.endsWith('/users')) { - return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + return Promise.resolve({ ok: true, json: () => Promise.resolve(opts.users ?? []) }); } if (url.endsWith('/groups/g2/impact')) { return Promise.resolve({ @@ -462,6 +463,39 @@ describe('GroupMembersModal (via AdminGroupsPage)', () => { const ldapRemove = screen.getByLabelText('Wird über AD-Sync verwaltet'); expect(ldapRemove).toBeDisabled(); }); + + it('die Benutzersuche stuerzt bei einem Konto ohne Adresse nicht ab', async () => { + // A second account without an address whose username/displayName do NOT + // match the search term is required to actually reach the unguarded + // `u.email.toLowerCase()` line — 'funktionskonto' alone always matches + // via its own username first (OR short-circuit), so a single-user + // fixture never exercises the crash (verified against unmodified code). + vi.stubGlobal( + 'fetch', + buildRouterFetchMock({ + users: [ + { id: 'u12', username: 'funktionskonto', displayName: null, email: null }, + { id: 'u13', username: 'anderes.konto', displayName: null, email: null }, + ], + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Buchhaltung')).toBeInTheDocument(); + }); + + const membersButtons = screen.getAllByText('Mitglieder'); + await userEvent.click(membersButtons[membersButtons.length - 1]); + + const searchInput = await screen.findByPlaceholderText('Benutzer suchen...'); + await userEvent.type(searchInput, 'funktion'); + + await waitFor(() => { + expect(screen.getByText('funktionskonto')).toBeInTheDocument(); + }); + }); }); describe('DeleteGroupDialog (via AdminGroupsPage)', () => { diff --git a/apps/web/src/app/(portal)/admin/ldap/page.tsx b/apps/web/src/app/(portal)/admin/ldap/page.tsx index 20688a1..2f925e5 100644 --- a/apps/web/src/app/(portal)/admin/ldap/page.tsx +++ b/apps/web/src/app/(portal)/admin/ldap/page.tsx @@ -72,6 +72,13 @@ interface SyncResult { groupsRenamed: number; groupsDeleted: number; defaultMarkerMoved: number; + // WINDOWS #15: Konten, die wegen einer bereits vergebenen Adresse ohne + // diese Adresse angelegt/aktualisiert wurden; Kennungen ohne + // Anmeldenamen (normaler Vorgang, kein Fehler); Kennungen mit einem + // unerwarteten Fehler (technischer Wortlaut bleibt im Serverprotokoll). + emailConflicts: { account: string; email: string }[]; + skippedNoLogin: string[]; + entryFailures: string[]; errors: string[]; } @@ -1338,6 +1345,45 @@ export default function AdminLdapPage() { })}

)} + {syncResult.emailConflicts.length > 0 && ( +
+

+ {t('sync.emailConflictsHeading')} +

+ {syncResult.emailConflicts.map((conflict, i) => ( +

+ {t('sync.emailConflictLine', { + account: conflict.account, + email: conflict.email, + })} +

+ ))} +
+ )} + {syncResult.skippedNoLogin.length > 0 && ( +
+

+ {t('sync.skippedNoLoginHeading')} +

+ {syncResult.skippedNoLogin.map((entry, i) => ( +

+ {entry} +

+ ))} +
+ )} + {syncResult.entryFailures.length > 0 && ( +
+

+ {t('sync.entryFailuresHeading')} +

+ {syncResult.entryFailures.map((entry, i) => ( +

+ {entry} +

+ ))} +
+ )} {syncResult.errors.length > 0 && (
{syncResult.errors.map((err, i) => ( diff --git a/apps/web/src/app/(portal)/admin/users/page.tsx b/apps/web/src/app/(portal)/admin/users/page.tsx index 4dc0b2d..6dd252e 100644 --- a/apps/web/src/app/(portal)/admin/users/page.tsx +++ b/apps/web/src/app/(portal)/admin/users/page.tsx @@ -10,7 +10,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; interface User { id: string; username: string; - email: string; + // WINDOWS #15: ein Konto ohne Adresse (kollidierte AD-Adresse) ist + // moeglich, seit User.email optional ist. Die Handanlage (UserFormData) + // verlangt weiterhin eine Adresse. + email: string | null; displayName: string | null; role: 'SUPER_ADMIN' | 'ADMIN' | 'USER'; isActive: boolean; @@ -93,7 +96,7 @@ export default function AdminUsersPage() { setEditingUser(user); setFormData({ username: user.username, - email: user.email, + email: user.email ?? '', password: '', displayName: user.displayName ?? '', role: user.role, @@ -222,7 +225,7 @@ export default function AdminUsersPage() { {user.username} - {user.email} + {user.email ?? '–'} {user.displayName ?? '-'} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index a7b5f97..4777773 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -336,6 +336,10 @@ "resultGroupMemberships": "Gruppenmitgliedschaften: {groupMembershipsAdded} hinzugefügt, {groupMembershipsRemoved} entfernt", "resultGroups": "AD-Gruppen: {groupsAdopted} neu übernommen, {groupsRenamed} umbenannt, {groupsDeleted} gelöscht", "defaultMarkerMoved": "Standardgruppen-Markierung musste neu vergeben werden ({defaultMarkerMoved}×).", + "emailConflictsHeading": "Diese Konten wurden ohne E-Mail-Adresse angelegt, weil die Adresse bereits zu einem anderen Konto gehört. Anmeldung und Zugriff funktionieren, nur Benachrichtigungen per E-Mail erreichen sie nicht.", + "emailConflictLine": "{account} — Adresse bereits vergeben an ein anderes Konto: {email}", + "skippedNoLoginHeading": "Ohne Anmeldenamen übersprungen — normal für Kontakte und Verteiler im Verzeichnis.", + "entryFailuresHeading": "Bei diesen Einträgen trat ein unerwarteter Fehler auf. Die technischen Angaben stehen im Protokoll des Servers.", "requestError": "Die Synchronisation konnte nicht ausgeführt werden." }, "groupFilter": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index a4529a4..2c49292 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -336,6 +336,10 @@ "resultGroupMemberships": "Group memberships: {groupMembershipsAdded} added, {groupMembershipsRemoved} removed", "resultGroups": "AD groups: {groupsAdopted} newly adopted, {groupsRenamed} renamed, {groupsDeleted} deleted", "defaultMarkerMoved": "Default-group marker had to be reassigned ({defaultMarkerMoved}×).", + "emailConflictsHeading": "These accounts were created without an email address because the address already belongs to another account. Login and access work fine, only email notifications will not reach them.", + "emailConflictLine": "{account} — address already taken by another account: {email}", + "skippedNoLoginHeading": "Skipped for lacking a login name — normal for contacts and distribution lists in the directory.", + "entryFailuresHeading": "An unexpected error occurred for these entries. The technical details are in the server log.", "requestError": "The synchronization could not be run." }, "groupFilter": {