feat(15-06): /admin/groups table + create/rename modal with AD radio-select binding

- page.tsx: sixth admin route, table (Name/AD-Bindung/Standardgruppe/
  Mitglieder/Aktionen), empty state matching AdminUsersPage's noUsers
  pattern, optimistic default-group star toggle (PATCH /groups/:id
  isDefault) with rollback + visible error div on failure, full refetch
  on success since setting one group default unsets all others server-side
  (D-13 transaction)
- GroupFormModal.tsx: create/rename dialog; AD binding section reuses
  GET /ldap/groups (D-18) with a radio list (D-05: exactly one AD group
  per Tessera group) instead of the LDAP page's checkbox multi-select;
  visible discoverError/noResults states instead of a silent-empty list
  (UI-SPEC backstop); create-with-binding does POST then a second PATCH
  since CreateGroupDto only accepts `name`
- groups-page.test.tsx: empty state, populated table, star-toggle
  optimistic PATCH + rollback-on-failure
This commit is contained in:
2026-08-04 18:57:43 +02:00
parent 90dc3981d5
commit c3ba1f74ea
3 changed files with 758 additions and 0 deletions
@@ -0,0 +1,255 @@
'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';
interface LdapDirectoryEntry {
dn: string;
name: string;
type: 'group' | 'ou';
}
interface GroupFormModalProps {
group: Group | null;
onClose: () => void;
onSaved: () => void;
}
/**
* Create/rename dialog for a Tessera group, including the optional AD
* binding (D-05, D-18): the AD group is picked from the existing LDAP
* group/OU discovery (GET /ldap/groups), never typed in by hand, and the
* selection is a radio list — exactly one AD group per Tessera group.
*/
export function GroupFormModal({ group, onClose, onSaved }: GroupFormModalProps) {
const t = useTranslations('admin.groups');
const tLdap = useTranslations('admin.ldap');
const tCommon = useTranslations('common');
const [name, setName] = useState(group?.name ?? '');
const [ldapDn, setLdapDn] = useState<string | null>(group?.ldapDn ?? null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [discovered, setDiscovered] = useState<LdapDirectoryEntry[] | null>(null);
const [discovering, setDiscovering] = useState(false);
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [discoverSearch, setDiscoverSearch] = useState('');
const fetchLdapGroups = useCallback(async () => {
setDiscovering(true);
setDiscoverError(null);
try {
const res = await fetch(`${API_URL}/ldap/groups`, {
credentials: 'include',
});
if (res.ok) {
setDiscovered(await res.json());
} else {
setDiscoverError(t('ldapBind.discoverError'));
}
} catch {
setDiscoverError(t('ldapBind.discoverError'));
} finally {
setDiscovering(false);
}
}, [t]);
// Only discover while unbound: a bound group shows the DN chip instead
// of the search UI (see render below), so there is nothing to fetch for.
useEffect(() => {
if (ldapDn === null && discovered === null && !discovering && !discoverError) {
fetchLdapGroups();
}
}, [ldapDn, discovered, discovering, discoverError, fetchLdapGroups]);
const filteredDiscovered = useMemo(() => {
const q = discoverSearch.trim().toLowerCase();
if (!q) return discovered;
return discovered?.filter(
(entry) => entry.name.toLowerCase().includes(q) || entry.dn.toLowerCase().includes(q),
);
}, [discovered, discoverSearch]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setSaveError(null);
try {
if (group) {
const res = await fetch(`${API_URL}/groups/${group.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name, ldapDn }),
});
if (!res.ok) {
setSaveError(t('saveError'));
return;
}
} else {
// CreateGroupDto only accepts `name` — a binding chosen while
// creating is applied with a second PATCH once the group exists.
const createRes = await fetch(`${API_URL}/groups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name }),
});
if (!createRes.ok) {
setSaveError(t('saveError'));
return;
}
const created = await createRes.json();
if (ldapDn) {
const bindRes = await fetch(`${API_URL}/groups/${created.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ldapDn }),
});
if (!bindRes.ok) {
setSaveError(t('saveError'));
return;
}
}
}
onSaved();
onClose();
} catch {
setSaveError(t('saveError'));
} finally {
setSaving(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">
{group ? t('edit') : t('create')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{t('name')}</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">{t('ldapBinding')}</label>
{ldapDn !== null ? (
<div className="space-y-2">
<p className="text-sm text-foreground">
{t('ldapBind.bound', { ldapDn })}
</p>
<button
type="button"
onClick={() => {
setLdapDn(null);
setDiscoverError(null);
}}
className="text-sm font-medium text-primary hover:underline"
>
{t('ldapBind.unbind')}
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{t('ldapBind.hint')}</p>
{discovering && (
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
)}
{discoverError && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{discoverError}
</div>
)}
{!discovering && !discoverError && discovered && discovered.length > 0 && (
<div className="space-y-2">
<input
type="text"
value={discoverSearch}
onChange={(e) => setDiscoverSearch(e.target.value)}
placeholder={t('ldapBind.searchPlaceholder')}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
/>
<div className="max-h-64 overflow-y-auto rounded-md border border-border divide-y divide-border">
{filteredDiscovered && filteredDiscovered.length > 0 ? (
filteredDiscovered.map((entry) => (
<label
key={entry.dn}
className="flex items-center gap-3 px-4 py-2 text-sm hover:bg-muted/30 cursor-pointer"
>
<input
type="radio"
name="ldapGroupBinding"
checked={ldapDn === entry.dn}
onChange={() => setLdapDn(entry.dn)}
/>
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{entry.type === 'ou'
? tLdap('groupFilter.typeOu')
: tLdap('groupFilter.typeGroup')}
</span>
<span className="font-medium text-foreground">{entry.name}</span>
<span className="font-mono text-xs text-muted-foreground truncate">
{entry.dn}
</span>
</label>
))
) : (
<p className="px-4 py-3 text-sm text-muted-foreground">
{t('ldapBind.noResults')}
</p>
)}
</div>
</div>
)}
{!discovering && !discoverError && discovered && discovered.length === 0 && (
<p className="text-sm text-muted-foreground">{t('ldapBind.noResults')}</p>
)}
</div>
)}
</div>
{saveError && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{saveError}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<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('cancel')}
</button>
<button
type="submit"
disabled={saving}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
>
{saving ? tCommon('loading') : tCommon('save')}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,244 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Namespace-aware next-intl mock: dot-path keys resolve against the
// namespace passed to useTranslations(ns), and {param} placeholders are
// substituted the same way next-intl does for plain (non-ICU) values.
const messages: Record<string, Record<string, unknown>> = {
'admin.groups': {
title: 'Gruppen',
create: 'Gruppe erstellen',
edit: 'Gruppe bearbeiten',
delete: 'Gruppe löschen',
noGroups: 'Keine Gruppen vorhanden',
noGroupsBody: 'Lege deine erste Gruppe an, um Modulzugriff gezielt zu vergeben.',
name: 'Name',
ldapBinding: 'AD-Bindung',
boundBadge: 'AD-gebunden',
manualBadge: 'Manuell',
defaultGroup: 'Standardgruppe',
setDefault: 'Als Standardgruppe festlegen',
isDefault: 'Standardgruppe',
memberCount: 'Mitglieder',
actions: 'Aktionen',
membersButton: 'Mitglieder',
saveError: 'Gruppe konnte nicht gespeichert werden. Bitte erneut versuchen.',
deleteError: 'Gruppe konnte nicht gelöscht werden. Bitte erneut versuchen.',
ldapBind: {
hint: 'Wähle eine AD-Gruppe aus der Liste, um die Mitgliedschaft automatisch zu synchronisieren.',
bound: 'Gebunden an: {ldapDn}',
unbind: 'Bindung entfernen',
searchPlaceholder: 'AD-Gruppen durchsuchen...',
discoverError: 'AD-Gruppen konnten nicht geladen werden. Bitte erneut versuchen.',
noResults: 'Keine AD-Gruppen gefunden.',
},
members: {
title: 'Mitglieder',
remove: 'Mitglied entfernen',
ldapManaged: 'Wird über AD-Sync verwaltet',
sourceManual: 'Manuell',
sourceLdap: 'LDAP',
addTitle: 'Mitglied hinzufügen',
searchPlaceholder: 'Benutzer suchen...',
noMembers: 'Keine Mitglieder vorhanden.',
addError: 'Mitglied konnte nicht hinzugefügt werden. Bitte erneut versuchen.',
},
deleteConfirm: {
title: 'Gruppe löschen',
body:
'Diese Gruppe hat {memberCount} Mitglieder und {grantCount} Modul-Freigaben. Betroffene Benutzer verlieren den Zugriff, sofern sie ihn nicht anderweitig haben. Diese Aktion kann nicht rückgängig gemacht werden.',
},
},
'admin.ldap': {
groupFilter: { typeOu: 'OU', typeGroup: 'Gruppe' },
},
common: {
loading: 'Laden...',
cancel: 'Abbrechen',
save: 'Speichern',
delete: 'Löschen',
edit: 'Bearbeiten',
accessDenied: 'Zugriff verweigert',
},
};
function resolve(ns: string, key: string, params?: Record<string, unknown>): string {
const parts = key.split('.');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let val: any = messages[ns] ?? {};
for (const part of parts) {
val = val?.[part];
}
if (typeof val !== 'string') return key;
if (params) {
for (const [k, v] of Object.entries(params)) {
val = val.replace(`{${k}}`, String(v));
}
}
return val;
}
vi.mock('next-intl', () => ({
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
resolve(ns, key, params),
}));
const mockAuthStore = vi.fn();
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
}));
import AdminGroupsPage from './page';
interface Group {
id: string;
tenantId: string;
name: string;
ldapDn: string | null;
isDefault: boolean;
createdAt: string;
updatedAt: string;
memberCount: number;
}
const mockGroups: Group[] = [
{
id: 'g1',
tenantId: 't1',
name: 'Alle Benutzer',
ldapDn: null,
isDefault: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
memberCount: 3,
},
{
id: 'g2',
tenantId: 't1',
name: 'Buchhaltung',
ldapDn: 'CN=Buchhaltung,OU=Gruppen,DC=ctl,DC=local',
isDefault: false,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
memberCount: 1,
},
];
function stubAdmin() {
mockAuthStore.mockImplementation(
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
selector({ user: { id: 'u1', role: 'ADMIN', tenantId: 't1' } }),
);
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('AdminGroupsPage', () => {
beforeEach(() => {
stubAdmin();
});
it('renders the empty state with heading, body and CTA when no groups exist', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([]) })),
);
render(<AdminGroupsPage />);
await waitFor(() => {
expect(screen.getByText('Keine Gruppen vorhanden')).toBeInTheDocument();
});
expect(
screen.getByText('Lege deine erste Gruppe an, um Modulzugriff gezielt zu vergeben.'),
).toBeInTheDocument();
// Two "Gruppe erstellen" CTAs exist once empty (header + empty-state body).
expect(screen.getAllByText('Gruppe erstellen').length).toBeGreaterThanOrEqual(1);
});
it('renders a populated table with name, AD-binding badge, default star and member count', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) })),
);
render(<AdminGroupsPage />);
await waitFor(() => {
expect(screen.getByText('Alle Benutzer')).toBeInTheDocument();
});
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
expect(screen.getByText('AD-gebunden')).toBeInTheDocument();
expect(screen.getByText('Manuell')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('1')).toBeInTheDocument();
});
it('toggles the default-group star optimistically and persists via PATCH', async () => {
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
if (typeof url === 'string' && url.endsWith('/groups') && (!init || init.method === undefined)) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) });
}
if (typeof url === 'string' && url.includes('/groups/g2') && init?.method === 'PATCH') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({ ...mockGroups[1], isDefault: true }) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminGroupsPage />);
await waitFor(() => {
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
});
const setDefaultButtons = screen.getAllByLabelText('Als Standardgruppe festlegen');
// 'Buchhaltung' (g2) is the non-default row — its star button carries the "set" label.
await userEvent.click(setDefaultButtons[0]);
await waitFor(() => {
const patchCall = fetchMock.mock.calls.find(
(call) =>
typeof call[0] === 'string' &&
call[0].includes('/groups/g2') &&
(call[1] as RequestInit | undefined)?.method === 'PATCH',
);
expect(patchCall).toBeDefined();
const body = JSON.parse((patchCall?.[1] as RequestInit).body as string);
expect(body).toEqual({ isDefault: true });
});
});
it('rolls back the star toggle when the PATCH request fails', async () => {
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
if (typeof url === 'string' && url.endsWith('/groups') && !init) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) });
}
if (typeof url === 'string' && url.includes('/groups/g2') && init?.method === 'PATCH') {
return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockGroups) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminGroupsPage />);
await waitFor(() => {
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
});
const setDefaultButtons = screen.getAllByLabelText('Als Standardgruppe festlegen');
await userEvent.click(setDefaultButtons[0]);
await waitFor(() => {
// The visible error div renders the status+body text.
expect(screen.getByText(/500: boom/)).toBeInTheDocument();
});
// Buchhaltung's star reverts to "set default" (not yet default again).
expect(screen.getAllByLabelText('Als Standardgruppe festlegen').length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,259 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store';
import { GroupFormModal } from './components/GroupFormModal';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export interface Group {
id: string;
tenantId: string;
name: string;
ldapDn: string | null;
isDefault: boolean;
createdAt: string;
updatedAt: string;
memberCount: number;
}
/**
* Admin group management page (PERM-01, D-14). Sixth admin nav entry.
* ADMIN and SUPER_ADMIN can access — the role check here is display only,
* not enforcement; every /groups route is protected server-side by
* RolesGuard (T-15-22).
*/
export default function AdminGroupsPage() {
const t = useTranslations('admin.groups');
const tCommon = useTranslations('common');
const currentUser = useAuthStore((s) => s.user);
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [togglingDefaultId, setTogglingDefaultId] = useState<string | null>(null);
const [showFormModal, setShowFormModal] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [membersGroup, setMembersGroup] = useState<Group | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Group | null>(null);
const hasAccess =
currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const fetchGroups = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/groups`, { credentials: 'include' });
if (res.ok) {
setGroups(await res.json());
}
} catch {
// silently fail — matches the read-path precedent of the other admin pages
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (hasAccess) {
fetchGroups();
} else {
setLoading(false);
}
}, [hasAccess, fetchGroups]);
const openCreate = () => {
setEditingGroup(null);
setShowFormModal(true);
};
const openEdit = (group: Group) => {
setEditingGroup(group);
setShowFormModal(true);
};
const handleToggleDefault = async (group: Group) => {
setTogglingDefaultId(group.id);
setError(null);
const nextValue = !group.isDefault;
// Optimistic update, same pattern as AdminModulesPage.toggleModule.
setGroups((prev) =>
prev.map((g) => (g.id === group.id ? { ...g, isDefault: nextValue } : g)),
);
try {
const res = await fetch(`${API_URL}/groups/${group.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ isDefault: nextValue }),
});
if (res.ok) {
// Setting one group's default flag unsets it on every other group
// in the tenant (D-13, server-side transaction) — a full refetch
// is the only way the table reflects that exclusivity correctly.
fetchGroups();
} else {
setGroups((prev) =>
prev.map((g) => (g.id === group.id ? { ...g, isDefault: group.isDefault } : g)),
);
const body = await res.text().catch(() => '');
setError(`${res.status}: ${body}`);
}
} catch (err) {
setGroups((prev) =>
prev.map((g) => (g.id === group.id ? { ...g, isDefault: group.isDefault } : g)),
);
setError(String(err));
} finally {
setTogglingDefaultId(null);
}
};
if (!hasAccess) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<button
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('create')}
</button>
</div>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="text-lg font-semibold text-foreground mb-2">{t('noGroups')}</h2>
<p className="text-sm text-muted-foreground mb-6">{t('noGroupsBody')}</p>
<button
onClick={openCreate}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('create')}
</button>
</div>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('ldapBinding')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('defaultGroup')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('memberCount')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
{t('actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{groups.map((group) => (
<tr key={group.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">{group.name}</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
group.ldapDn
? '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'
}`}
>
{group.ldapDn ? t('boundBadge') : t('manualBadge')}
</span>
</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => handleToggleDefault(group)}
disabled={togglingDefaultId === group.id}
aria-label={group.isDefault ? t('isDefault') : t('setDefault')}
title={group.isDefault ? t('isDefault') : t('setDefault')}
className={`rounded px-2 py-1 transition-colors disabled:opacity-50 disabled:cursor-wait ${
group.isDefault
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill={group.isDefault ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
</td>
<td className="px-4 py-3 text-muted-foreground">{group.memberCount}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEdit(group)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{tCommon('edit')}
</button>
<button
onClick={() => setMembersGroup(group)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
>
{t('membersButton')}
</button>
<button
onClick={() => setDeleteTarget(group)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
>
{tCommon('delete')}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showFormModal && (
<GroupFormModal
group={editingGroup}
onClose={() => setShowFormModal(false)}
onSaved={fetchGroups}
/>
)}
{/* GroupMembersModal / DeleteGroupDialog are wired in here by Task 3
(member/delete state above is already in place for that). */}
</div>
);
}