feat(15-06): group member management + delete dialog with concrete impact numbers
- GroupMembersModal.tsx: chip list of current members with source badge (MANUAL/LDAP); LDAP-sourced chips carry a disabled remove button with a "managed via AD sync" tooltip (D-19) instead of an active one; second section adds manual members via GET /users + POST /groups/:id/members, already-member candidates shown disabled (upsert on the API is folgenlos, no special-case needed) - DeleteGroupDialog.tsx: loads GET /groups/:id/impact and interpolates memberCount/grantCount into the confirmation text (D-17); deliberately breaks from the project's silent-delete-failure precedent -- stays open and shows a visible error on a failed DELETE, since a silent failure here would leave an admin believing a group (and its grants) is gone while it still grants access (T-15-24) - page.tsx: wires both dialogs in, refetches the group list after any member/delete mutation so member counts and badges stay current - groups-page.test.tsx: disabled-vs-active remove button by membership source, delete text shows both numbers, visible error + dialog stays open on failed delete
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import type { Group } from '../page';
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
interface GroupImpact {
|
||||||
|
memberCount: number;
|
||||||
|
grantCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteGroupDialogProps {
|
||||||
|
group: Group;
|
||||||
|
onClose: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete-confirmation dialog for a group (D-17, PERM-01).
|
||||||
|
*
|
||||||
|
* DELIBERATE DEVIATION from the silent-fail precedent used by every other
|
||||||
|
* delete dialog in this project (AdminUsersPage, AdminTenantsPage): this
|
||||||
|
* dialog promises concrete numbers (memberCount/grantCount) and stays open
|
||||||
|
* with a visible error on failure instead of failing silently. A silent
|
||||||
|
* failure here would let an admin believe a group (and its grants) is
|
||||||
|
* gone while it still grants access — see T-15-24 in the plan's threat
|
||||||
|
* model.
|
||||||
|
*/
|
||||||
|
export function DeleteGroupDialog({ group, onClose, onDeleted }: DeleteGroupDialogProps) {
|
||||||
|
const t = useTranslations('admin.groups');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
|
||||||
|
const [impact, setImpact] = useState<GroupImpact | null>(null);
|
||||||
|
const [loadingImpact, setLoadingImpact] = useState(true);
|
||||||
|
const [impactError, setImpactError] = useState(false);
|
||||||
|
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/groups/${group.id}/impact`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!cancelled) {
|
||||||
|
if (res.ok) {
|
||||||
|
setImpact(await res.json());
|
||||||
|
} else {
|
||||||
|
setImpactError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setImpactError(true);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoadingImpact(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [group.id]);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
setDeleteError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/groups/${group.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
onDeleted();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// Stays open on purpose — see the DELIBERATE DEVIATION note above.
|
||||||
|
setDeleteError(t('deleteError'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDeleteError(t('deleteError'));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-sm rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-4">{t('deleteConfirm.title')}</h2>
|
||||||
|
|
||||||
|
{loadingImpact ? (
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">{tCommon('loading')}</p>
|
||||||
|
) : impactError ? (
|
||||||
|
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{t('deleteError')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-foreground mb-4">
|
||||||
|
{t('deleteConfirm.body', {
|
||||||
|
memberCount: impact?.memberCount ?? 0,
|
||||||
|
grantCount: impact?.grantCount ?? 0,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteError && (
|
||||||
|
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{deleteError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting || loadingImpact || impactError}
|
||||||
|
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? tCommon('loading') : tCommon('delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import type { Group } from '../page';
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
type MembershipSource = 'MANUAL' | 'LDAP';
|
||||||
|
|
||||||
|
interface GroupMembership {
|
||||||
|
id: string;
|
||||||
|
groupId: string;
|
||||||
|
userId: string;
|
||||||
|
source: MembershipSource;
|
||||||
|
createdAt: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TenantUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMembersModalProps {
|
||||||
|
group: Group;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Member management dialog for a single group (D-14, D-19, D-20).
|
||||||
|
*
|
||||||
|
* LDAP-sourced memberships can only be removed by the AD sync — the
|
||||||
|
* remove button here is disabled for them with a tooltip explaining why
|
||||||
|
* (D-19). Manual memberships can be added and removed freely; adding a
|
||||||
|
* user who is already a member is a folgenlos upsert on the API side,
|
||||||
|
* so the search list simply marks already-members as disabled rather
|
||||||
|
* than needing special-case handling here.
|
||||||
|
*/
|
||||||
|
export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersModalProps) {
|
||||||
|
const t = useTranslations('admin.groups');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<GroupMembership[] | null>(null);
|
||||||
|
const [loadingMembers, setLoadingMembers] = useState(true);
|
||||||
|
|
||||||
|
const [allUsers, setAllUsers] = useState<TenantUser[] | null>(null);
|
||||||
|
const [userSearchQuery, setUserSearchQuery] = useState('');
|
||||||
|
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/groups/${group.id}/members`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setMembers(await res.json());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail — matches the read-path precedent of the other admin pages
|
||||||
|
} finally {
|
||||||
|
setLoadingMembers(false);
|
||||||
|
}
|
||||||
|
}, [group.id]);
|
||||||
|
|
||||||
|
const fetchAllUsers = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/users`, { credentials: 'include' });
|
||||||
|
if (res.ok) {
|
||||||
|
setAllUsers(await res.json());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMembers();
|
||||||
|
fetchAllUsers();
|
||||||
|
// group.id is stable for the lifetime of this modal instance.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const memberUserIds = useMemo(() => new Set(members?.map((m) => m.userId) ?? []), [members]);
|
||||||
|
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
if (!allUsers) return null;
|
||||||
|
const q = userSearchQuery.trim().toLowerCase();
|
||||||
|
if (!q) return allUsers;
|
||||||
|
return allUsers.filter(
|
||||||
|
(u) =>
|
||||||
|
u.username.toLowerCase().includes(q) ||
|
||||||
|
(u.displayName ?? '').toLowerCase().includes(q) ||
|
||||||
|
u.email.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [allUsers, userSearchQuery]);
|
||||||
|
|
||||||
|
const toggleSelected = (userId: string) => {
|
||||||
|
setSelectedUserIds((prev) =>
|
||||||
|
prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/groups/${group.id}/members/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchMembers();
|
||||||
|
onChanged();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail — mirrors the existing chip-remove precedent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (selectedUserIds.length === 0) return;
|
||||||
|
setAdding(true);
|
||||||
|
setAddError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/groups/${group.id}/members`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ userIds: selectedUserIds }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setSelectedUserIds([]);
|
||||||
|
fetchMembers();
|
||||||
|
onChanged();
|
||||||
|
} else {
|
||||||
|
setAddError(t('members.addError'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setAddError(t('members.addError'));
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||||
|
{t('members.title')} — {group.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
{loadingMembers ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
|
||||||
|
) : !members || members.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('members.noMembers')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-wrap gap-2">
|
||||||
|
{members.map((m) => (
|
||||||
|
<li
|
||||||
|
key={m.id}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-foreground">
|
||||||
|
{m.user.displayName ?? m.user.username}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||||
|
m.source === 'LDAP'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||||
|
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.source === 'LDAP' ? t('members.sourceLdap') : t('members.sourceManual')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(m.userId)}
|
||||||
|
disabled={m.source === 'LDAP'}
|
||||||
|
aria-label={
|
||||||
|
m.source === 'LDAP' ? t('members.ldapManaged') : t('members.remove')
|
||||||
|
}
|
||||||
|
title={m.source === 'LDAP' ? t('members.ldapManaged') : t('members.remove')}
|
||||||
|
className="shrink-0 rounded px-1.5 py-0.5 text-xs text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
{t('members.remove')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-2">{t('members.addTitle')}</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userSearchQuery}
|
||||||
|
onChange={(e) => setUserSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('members.searchPlaceholder')}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm mb-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{searchResults && searchResults.length > 0 && (
|
||||||
|
<div className="mb-3 max-h-48 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||||
|
{searchResults.map((u) => {
|
||||||
|
const alreadyMember = memberUserIds.has(u.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={u.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-2 text-sm ${
|
||||||
|
alreadyMember ? 'opacity-60' : 'hover:bg-muted/30 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={alreadyMember}
|
||||||
|
checked={selectedUserIds.includes(u.id)}
|
||||||
|
onChange={() => toggleSelected(u.id)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{u.displayName || u.username}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{u.email}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{addError && (
|
||||||
|
<div className="mb-3 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{addError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md border border-border px-4 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{tCommon('close')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={adding || selectedUserIds.length === 0}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{adding ? tCommon('loading') : `${t('members.addTitle')} (${selectedUserIds.length})`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -242,3 +242,152 @@ describe('AdminGroupsPage', () => {
|
|||||||
expect(screen.getAllByLabelText('Als Standardgruppe festlegen').length).toBeGreaterThan(0);
|
expect(screen.getAllByLabelText('Als Standardgruppe festlegen').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface GroupMembership {
|
||||||
|
id: string;
|
||||||
|
groupId: string;
|
||||||
|
userId: string;
|
||||||
|
source: 'MANUAL' | 'LDAP';
|
||||||
|
createdAt: string;
|
||||||
|
user: { id: string; username: string; displayName: string | null; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockMembers: GroupMembership[] = [
|
||||||
|
{
|
||||||
|
id: 'gm1',
|
||||||
|
groupId: 'g2',
|
||||||
|
userId: 'u10',
|
||||||
|
source: 'MANUAL',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
user: { id: 'u10', username: 'manual.user', displayName: 'Manuelle Userin', email: 'manual@ctl.de' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gm2',
|
||||||
|
groupId: 'g2',
|
||||||
|
userId: 'u11',
|
||||||
|
source: 'LDAP',
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
user: { id: 'u11', username: 'ad.user', displayName: 'AD Userin', email: 'ad@ctl.de' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildRouterFetchMock(opts: {
|
||||||
|
impact?: { memberCount: number; grantCount: number };
|
||||||
|
deleteOk?: boolean;
|
||||||
|
}) {
|
||||||
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
|
if (typeof url !== 'string') {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
}
|
||||||
|
if (url.endsWith('/groups') && (!init || init.method === undefined)) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) });
|
||||||
|
}
|
||||||
|
if (url.endsWith('/groups/g2/members') && (!init || init.method === undefined)) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockMembers) });
|
||||||
|
}
|
||||||
|
if (url.endsWith('/users')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
}
|
||||||
|
if (url.endsWith('/groups/g2/impact')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(opts.impact ?? { memberCount: 4, grantCount: 2 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.endsWith('/groups/g2') && init?.method === 'DELETE') {
|
||||||
|
if (opts.deleteOk === false) {
|
||||||
|
return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('db down') });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupMembersModal (via AdminGroupsPage)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the remove button for LDAP-sourced members and keeps it active for MANUAL members', async () => {
|
||||||
|
vi.stubGlobal('fetch', buildRouterFetchMock({}));
|
||||||
|
|
||||||
|
render(<AdminGroupsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const membersButtons = screen.getAllByText('Mitglieder');
|
||||||
|
// Header column label + per-row action buttons all render the same text;
|
||||||
|
// the row action for 'Buchhaltung' (g2) is the last "Mitglieder" button.
|
||||||
|
await userEvent.click(membersButtons[membersButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Manuelle Userin')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('AD Userin')).toBeInTheDocument();
|
||||||
|
|
||||||
|
const manualRemove = screen.getByLabelText('Mitglied entfernen');
|
||||||
|
expect(manualRemove).not.toBeDisabled();
|
||||||
|
|
||||||
|
const ldapRemove = screen.getByLabelText('Wird über AD-Sync verwaltet');
|
||||||
|
expect(ldapRemove).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DeleteGroupDialog (via AdminGroupsPage)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubAdmin();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows both concrete numbers from GET /groups/:id/impact in the delete text', async () => {
|
||||||
|
vi.stubGlobal('fetch', buildRouterFetchMock({ impact: { memberCount: 4, grantCount: 2 } }));
|
||||||
|
|
||||||
|
render(<AdminGroupsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/4 Mitglieder/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/2 Modul-Freigaben/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays open and shows a visible error when DELETE /groups/:id fails', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
buildRouterFetchMock({ impact: { memberCount: 4, grantCount: 2 }, deleteOk: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<AdminGroupsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/4 Mitglieder/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm button inside the dialog also reads "Löschen" (tCommon.delete).
|
||||||
|
const confirmButtons = screen.getAllByText('Löschen');
|
||||||
|
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('Gruppe konnte nicht gelöscht werden. Bitte erneut versuchen.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
// The dialog is still open — its title is still on screen.
|
||||||
|
expect(screen.getByText('Gruppe löschen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
import { GroupFormModal } from './components/GroupFormModal';
|
import { GroupFormModal } from './components/GroupFormModal';
|
||||||
|
import { GroupMembersModal } from './components/GroupMembersModal';
|
||||||
|
import { DeleteGroupDialog } from './components/DeleteGroupDialog';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
@@ -252,8 +254,21 @@ export default function AdminGroupsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GroupMembersModal / DeleteGroupDialog are wired in here by Task 3
|
{membersGroup && (
|
||||||
(member/delete state above is already in place for that). */}
|
<GroupMembersModal
|
||||||
|
group={membersGroup}
|
||||||
|
onClose={() => setMembersGroup(null)}
|
||||||
|
onChanged={fetchGroups}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteGroupDialog
|
||||||
|
group={deleteTarget}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onDeleted={fetchGroups}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user