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:
@@ -26,7 +26,9 @@ interface TenantUser {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string | null;
|
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 {
|
interface GroupMembersModalProps {
|
||||||
@@ -101,7 +103,7 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
|
|||||||
(u) =>
|
(u) =>
|
||||||
u.username.toLowerCase().includes(q) ||
|
u.username.toLowerCase().includes(q) ||
|
||||||
(u.displayName ?? '').toLowerCase().includes(q) ||
|
(u.displayName ?? '').toLowerCase().includes(q) ||
|
||||||
u.email.toLowerCase().includes(q),
|
(u.email ?? '').toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
}, [allUsers, userSearchQuery]);
|
}, [allUsers, userSearchQuery]);
|
||||||
|
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ const mockMembers: GroupMembership[] = [
|
|||||||
function buildRouterFetchMock(opts: {
|
function buildRouterFetchMock(opts: {
|
||||||
impact?: { memberCount: number; grantCount: number };
|
impact?: { memberCount: number; grantCount: number };
|
||||||
deleteOk?: boolean;
|
deleteOk?: boolean;
|
||||||
|
users?: unknown[];
|
||||||
}) {
|
}) {
|
||||||
return vi.fn((url: string, init?: RequestInit) => {
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
if (typeof url !== 'string') {
|
if (typeof url !== 'string') {
|
||||||
@@ -414,7 +415,7 @@ function buildRouterFetchMock(opts: {
|
|||||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockMembers) });
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockMembers) });
|
||||||
}
|
}
|
||||||
if (url.endsWith('/users')) {
|
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')) {
|
if (url.endsWith('/groups/g2/impact')) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -462,6 +463,39 @@ describe('GroupMembersModal (via AdminGroupsPage)', () => {
|
|||||||
const ldapRemove = screen.getByLabelText('Wird über AD-Sync verwaltet');
|
const ldapRemove = screen.getByLabelText('Wird über AD-Sync verwaltet');
|
||||||
expect(ldapRemove).toBeDisabled();
|
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)', () => {
|
describe('DeleteGroupDialog (via AdminGroupsPage)', () => {
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ interface SyncResult {
|
|||||||
groupsRenamed: number;
|
groupsRenamed: number;
|
||||||
groupsDeleted: number;
|
groupsDeleted: number;
|
||||||
defaultMarkerMoved: 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[];
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1338,6 +1345,45 @@ export default function AdminLdapPage() {
|
|||||||
})}
|
})}
|
||||||
</p>
|
</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 && (
|
{syncResult.errors.length > 0 && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{syncResult.errors.map((err, i) => (
|
{syncResult.errors.map((err, i) => (
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
|||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: 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;
|
displayName: string | null;
|
||||||
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
role: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -93,7 +96,7 @@ export default function AdminUsersPage() {
|
|||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
setFormData({
|
setFormData({
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
displayName: user.displayName ?? '',
|
displayName: user.displayName ?? '',
|
||||||
role: user.role,
|
role: user.role,
|
||||||
@@ -222,7 +225,7 @@ export default function AdminUsersPage() {
|
|||||||
{user.username}
|
{user.username}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground">
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
{user.email}
|
{user.email ?? '–'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground">
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
{user.displayName ?? '-'}
|
{user.displayName ?? '-'}
|
||||||
|
|||||||
@@ -336,6 +336,10 @@
|
|||||||
"resultGroupMemberships": "Gruppenmitgliedschaften: {groupMembershipsAdded} hinzugefügt, {groupMembershipsRemoved} entfernt",
|
"resultGroupMemberships": "Gruppenmitgliedschaften: {groupMembershipsAdded} hinzugefügt, {groupMembershipsRemoved} entfernt",
|
||||||
"resultGroups": "AD-Gruppen: {groupsAdopted} neu übernommen, {groupsRenamed} umbenannt, {groupsDeleted} gelöscht",
|
"resultGroups": "AD-Gruppen: {groupsAdopted} neu übernommen, {groupsRenamed} umbenannt, {groupsDeleted} gelöscht",
|
||||||
"defaultMarkerMoved": "Standardgruppen-Markierung musste neu vergeben werden ({defaultMarkerMoved}×).",
|
"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."
|
"requestError": "Die Synchronisation konnte nicht ausgeführt werden."
|
||||||
},
|
},
|
||||||
"groupFilter": {
|
"groupFilter": {
|
||||||
|
|||||||
@@ -336,6 +336,10 @@
|
|||||||
"resultGroupMemberships": "Group memberships: {groupMembershipsAdded} added, {groupMembershipsRemoved} removed",
|
"resultGroupMemberships": "Group memberships: {groupMembershipsAdded} added, {groupMembershipsRemoved} removed",
|
||||||
"resultGroups": "AD groups: {groupsAdopted} newly adopted, {groupsRenamed} renamed, {groupsDeleted} deleted",
|
"resultGroups": "AD groups: {groupsAdopted} newly adopted, {groupsRenamed} renamed, {groupsDeleted} deleted",
|
||||||
"defaultMarkerMoved": "Default-group marker had to be reassigned ({defaultMarkerMoved}×).",
|
"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."
|
"requestError": "The synchronization could not be run."
|
||||||
},
|
},
|
||||||
"groupFilter": {
|
"groupFilter": {
|
||||||
|
|||||||
Reference in New Issue
Block a user