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