feat(quick-260909-ab3): verstaendlicher Sync-Bericht, Konten ohne Adresse in der UI

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
2026-09-09 07:54:32 +02:00
parent 1222951af6
commit 21670462dd
6 changed files with 99 additions and 6 deletions
@@ -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]);
@@ -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(<AdminGroupsPage />);
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)', () => {
@@ -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() {
})}
</p>
)}
{syncResult.emailConflicts.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
{t('sync.emailConflictsHeading')}
</p>
{syncResult.emailConflicts.map((conflict, i) => (
<p key={i} className="text-xs text-amber-700 dark:text-amber-400">
{t('sync.emailConflictLine', {
account: conflict.account,
email: conflict.email,
})}
</p>
))}
</div>
)}
{syncResult.skippedNoLogin.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-sm font-medium text-muted-foreground">
{t('sync.skippedNoLoginHeading')}
</p>
{syncResult.skippedNoLogin.map((entry, i) => (
<p key={i} className="text-xs text-muted-foreground">
{entry}
</p>
))}
</div>
)}
{syncResult.entryFailures.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-sm font-medium text-destructive">
{t('sync.entryFailuresHeading')}
</p>
{syncResult.entryFailures.map((entry, i) => (
<p key={i} className="text-xs text-destructive">
{entry}
</p>
))}
</div>
)}
{syncResult.errors.length > 0 && (
<div className="mt-2 space-y-1">
{syncResult.errors.map((err, i) => (
@@ -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}
</td>
<td className="px-4 py-3 text-muted-foreground">
{user.email}
{user.email ?? '–'}
</td>
<td className="px-4 py-3 text-muted-foreground">
{user.displayName ?? '-'}
+4
View File
@@ -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": {
+4
View File
@@ -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": {