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:
2026-08-04 19:02:11 +02:00
parent c3ba1f74ea
commit 4985e43412
4 changed files with 566 additions and 2 deletions
@@ -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>
); );
} }