'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'; import { GroupMembersModal } from './components/GroupMembersModal'; import { DeleteGroupDialog } from './components/DeleteGroupDialog'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; export interface Group { id: string; tenantId: string; name: string; ldapDn: string | null; internalName: 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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [togglingDefaultId, setTogglingDefaultId] = useState(null); const [showFormModal, setShowFormModal] = useState(false); const [editingGroup, setEditingGroup] = useState(null); const [membersGroup, setMembersGroup] = useState(null); const [deleteTarget, setDeleteTarget] = useState(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 (

{tCommon('accessDenied')}

); } return (

{t('title')}

{error && (
{error}
)} {loading ? (

{tCommon('loading')}

) : groups.length === 0 ? (

{t('noGroups')}

{t('noGroupsBody')}

) : (
{groups.map((group) => ( ))}
{t('name')} {t('ldapBinding')} {t('defaultGroup')} {t('memberCount')} {t('actions')}
{group.internalName ?? group.name} {group.ldapDn ? t('boundBadge') : t('manualBadge')} {group.memberCount}
)} {showFormModal && ( setShowFormModal(false)} onSaved={fetchGroups} /> )} {membersGroup && ( setMembersGroup(null)} onChanged={fetchGroups} /> )} {deleteTarget && ( setDeleteTarget(null)} onDeleted={fetchGroups} /> )}
); }