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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user