import { useState, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { ArrowLeft, Save, Loader2, Search, Check, Minus, Shield, AlertTriangle } from 'lucide-react'; import { api } from '../api/client'; import type { Permission, PermissionCategory } from '../api/client'; import { Button } from '../components/Button'; import { Card } from '../components/Card'; import { useToast } from '../contexts/ToastContext'; export function GroupEditPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { t } = useTranslation(); const { showToast } = useToast(); const isEditing = Boolean(id); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [permissions, setPermissions] = useState([]); const [search, setSearch] = useState(''); const [initialized, setInitialized] = useState(false); const { data: groupData, isLoading: groupLoading } = useQuery({ queryKey: ['group', id], queryFn: () => api.getGroup(Number(id)), enabled: isEditing, }); const { data: permissionsData, isLoading: permissionsLoading } = useQuery({ queryKey: ['permissions'], queryFn: () => api.getPermissions(), }); // Initialize form from fetched group data (once) if (isEditing && groupData && !initialized) { setName(groupData.name); setDescription(groupData.description || ''); setPermissions(groupData.permissions); setInitialized(true); } const createMutation = useMutation({ mutationFn: (data: { name: string; description?: string; permissions: Permission[] }) => api.createGroup(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['groups'] }); queryClient.invalidateQueries({ queryKey: ['group'] }); showToast(t('groups.toast.created')); navigate('/settings?tab=users'); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const updateMutation = useMutation({ mutationFn: (data: { name?: string; description?: string; permissions: Permission[] }) => api.updateGroup(Number(id), data), onSuccess: (updatedGroup) => { queryClient.invalidateQueries({ queryKey: ['groups'] }); // Prime the single-group detail cache with the PATCH response body so // reopening the editor within the 60s default staleTime shows the // newly-saved permissions instead of the stale pre-update snapshot // (#1083). setQueryData alone is enough — we intentionally do NOT also // invalidate ['group', id] because that would trigger an immediate // background refetch that could race with / overwrite this primed value // in test environments where the GET handler is a static mock; in // production the server's GET would match this payload anyway. if (updatedGroup) { queryClient.setQueryData(['group', id], updatedGroup); } showToast(t('groups.toast.updated')); navigate('/settings?tab=users'); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); const isSaving = createMutation.isPending || updateMutation.isPending; const handleSave = () => { if (!name.trim()) { showToast(t('groups.toast.enterGroupName'), 'error'); return; } if (isEditing) { updateMutation.mutate({ name: name !== groupData?.name ? name : undefined, description, permissions, }); } else { createMutation.mutate({ name, description: description || undefined, permissions, }); } }; const togglePermission = (perm: Permission) => { setPermissions((prev) => prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] ); }; const toggleCategoryPermissions = (category: PermissionCategory, checked: boolean) => { const categoryPerms = category.permissions.map((p) => p.value); setPermissions((prev) => { const otherPerms = prev.filter((p) => !categoryPerms.includes(p)); return checked ? [...otherPerms, ...categoryPerms] : otherPerms; }); }; const isCategoryFullySelected = (category: PermissionCategory) => category.permissions.every((p) => permissions.includes(p.value)); const isCategoryPartiallySelected = (category: PermissionCategory) => { const count = category.permissions.filter((p) => permissions.includes(p.value)).length; return count > 0 && count < category.permissions.length; }; const selectAll = () => { if (permissionsData) { setPermissions(permissionsData.all_permissions); } }; const clearAll = () => { setPermissions([]); }; const searchLower = search.toLowerCase(); const filteredCategories = useMemo(() => { if (!permissionsData) return []; if (!searchLower) return permissionsData.categories; return permissionsData.categories .map((cat) => ({ ...cat, permissions: cat.permissions.filter((p) => p.label.toLowerCase().includes(searchLower) ), })) .filter((cat) => cat.permissions.length > 0); }, [permissionsData, searchLower]); const totalPermissions = permissionsData?.all_permissions.length ?? 0; if (groupLoading || permissionsLoading) { return (
); } return (
{/* Header */}

{isEditing ? t('groups.editor.title') : t('groups.editor.createTitle')}

{/* System group warning */} {isEditing && groupData?.is_system && (
{t('groups.form.systemGroupWarning')}
)} {/* Name + Description */}
setName(e.target.value)} disabled={isEditing && groupData?.is_system} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50" placeholder={t('groups.form.groupNamePlaceholder')} />
setDescription(e.target.value)} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('groups.form.descriptionPlaceholder')} />
{/* Toolbar */}
{t('groups.editor.permissionsSelected', { count: permissions.length })} / {totalPermissions}
setSearch(e.target.value)} placeholder={t('groups.editor.search')} className="pl-9 pr-4 py-2 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors w-64" />
{/* Permission grid */} {filteredCategories.length === 0 ? (
{t('groups.editor.noResults')}
) : (
{filteredCategories.map((category) => { // Use the full (unfiltered) category for selection logic const fullCategory = permissionsData!.categories.find((c) => c.name === category.name)!; const selectedCount = fullCategory.permissions.filter((p) => permissions.includes(p.value)).length; const totalCount = fullCategory.permissions.length; const fullySelected = isCategoryFullySelected(fullCategory); const partiallySelected = isCategoryPartiallySelected(fullCategory); return (
{category.name}
{selectedCount}/{totalCount}
{category.permissions.map((perm) => ( ))}
); })}
)} {/* Spacer for fixed bottom bar */}
{/* Fixed bottom bar */}
); }