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;
|
||||
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 ?? '-'}
|
||||
|
||||
Reference in New Issue
Block a user