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": {
|