diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a58d33e..e42b4645a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ All notable changes to Bambuddy will be documented in this file. - **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit. - **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`. +### New Features +- **Clear Plate Permission** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — New `printers:clear_plate` permission allows admins to grant users the ability to confirm a plate is cleared for the next queued print without granting full `printers:control` (which also allows stopping prints, configuring AMS, toggling lights, etc.). Existing groups with `printers:control` automatically receive the new permission on startup. The Operators default group includes it by default. +- **Full-Page Group Permission Editor** ([#446](https://github.com/maziggy/bambuddy/issues/446)) — Replaced the cramped permission modal with a dedicated full-page editor at `/groups/:id/edit`. Features a responsive 2-column grid of always-expanded category cards, permission search/filtering, Select All / Clear All bulk actions, category-level checkboxes with partial state, and a fixed bottom action bar. The old `GroupsPage.tsx` dead code has been removed. + ### Changed - **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — Renamed `/api/v1/filaments/` to `/api/v1/filament-catalog/` to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory. diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 47d20de85..210ad7f8d 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -1862,7 +1862,7 @@ async def stop_print( @router.post("/{printer_id}/clear-plate") async def clear_plate( printer_id: int, - _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE), db: AsyncSession = Depends(get_db), ): """Acknowledge that the build plate has been cleared after a finished/failed print. diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 9467dcfed..39c276383 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1364,6 +1364,19 @@ async def seed_default_groups(): await session.commit() + # Migrate new permissions: grant printers:clear_plate to all groups with printers:control + result = await session.execute(select(Group)) + all_groups = result.scalars().all() + for group in all_groups: + if ( + group.permissions + and "printers:control" in group.permissions + and "printers:clear_plate" not in group.permissions + ): + group.permissions = [*group.permissions, "printers:clear_plate"] + logger.info("Added printers:clear_plate to group '%s' (has printers:control)", group.name) + await session.commit() + # Migrate existing users to groups if they're not already in any group if groups_created: # Refresh to get newly created groups diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index ff2ac4fd8..a90eaff2b 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -22,6 +22,7 @@ class Permission(StrEnum): PRINTERS_CONTROL = "printers:control" # Start/stop/pause/resume prints PRINTERS_FILES = "printers:files" # Send files to printer PRINTERS_AMS_RFID = "printers:ams_rfid" # Re-read AMS RFID tags + PRINTERS_CLEAR_PLATE = "printers:clear_plate" # Confirm plate cleared for next print # Archives ARCHIVES_READ = "archives:read" @@ -167,6 +168,7 @@ PERMISSION_CATEGORIES = { Permission.PRINTERS_CONTROL, Permission.PRINTERS_FILES, Permission.PRINTERS_AMS_RFID, + Permission.PRINTERS_CLEAR_PLATE, ], "Archives": [ Permission.ARCHIVES_READ, @@ -320,6 +322,7 @@ DEFAULT_GROUPS = { Permission.PRINTERS_CONTROL.value, Permission.PRINTERS_FILES.value, Permission.PRINTERS_AMS_RFID.value, + Permission.PRINTERS_CLEAR_PLATE.value, # Archives - own items only Permission.ARCHIVES_READ.value, Permission.ARCHIVES_CREATE.value, diff --git a/backend/tests/unit/test_permissions.py b/backend/tests/unit/test_permissions.py new file mode 100644 index 000000000..caa8c7156 --- /dev/null +++ b/backend/tests/unit/test_permissions.py @@ -0,0 +1,76 @@ +"""Tests for the permission system definitions and consistency.""" + +from backend.app.core.permissions import ( + ALL_PERMISSIONS, + DEFAULT_GROUPS, + PERMISSION_CATEGORIES, + Permission, +) + + +class TestPermissionEnum: + """Test the Permission enum values.""" + + def test_clear_plate_permission_exists(self): + """printers:clear_plate permission should exist in the enum.""" + assert hasattr(Permission, "PRINTERS_CLEAR_PLATE") + assert Permission.PRINTERS_CLEAR_PLATE == "printers:clear_plate" + + def test_clear_plate_in_all_permissions(self): + """printers:clear_plate should be in ALL_PERMISSIONS list.""" + assert "printers:clear_plate" in ALL_PERMISSIONS + + def test_clear_plate_in_printers_category(self): + """printers:clear_plate should be in the Printers permission category.""" + printers_perms = PERMISSION_CATEGORIES["Printers"] + assert Permission.PRINTERS_CLEAR_PLATE in printers_perms + + def test_clear_plate_separate_from_control(self): + """clear_plate and control should be distinct permissions.""" + assert Permission.PRINTERS_CLEAR_PLATE != Permission.PRINTERS_CONTROL + assert Permission.PRINTERS_CLEAR_PLATE.value != Permission.PRINTERS_CONTROL.value + + +class TestDefaultGroups: + """Test the default group definitions.""" + + def test_operators_have_clear_plate(self): + """Operators group should include printers:clear_plate.""" + operators = DEFAULT_GROUPS["Operators"] + assert "printers:clear_plate" in operators["permissions"] + + def test_operators_have_control_and_clear_plate(self): + """Operators group should have both printers:control and printers:clear_plate.""" + operators = DEFAULT_GROUPS["Operators"] + assert "printers:control" in operators["permissions"] + assert "printers:clear_plate" in operators["permissions"] + + def test_administrators_have_all_permissions(self): + """Administrators should have all permissions including clear_plate.""" + admins = DEFAULT_GROUPS["Administrators"] + assert "printers:clear_plate" in admins["permissions"] + + def test_viewers_do_not_have_clear_plate(self): + """Viewers group (read-only) should not include printers:clear_plate.""" + viewers = DEFAULT_GROUPS["Viewers"] + assert "printers:clear_plate" not in viewers["permissions"] + + +class TestPermissionCategoriesCompleteness: + """Test that all enum permissions appear in exactly one category.""" + + def test_all_permissions_categorized(self): + """Every Permission enum member should appear in a category.""" + categorized = set() + for perms in PERMISSION_CATEGORIES.values(): + categorized.update(perms) + for perm in Permission: + assert perm in categorized, f"{perm} not in any category" + + def test_no_duplicate_categorization(self): + """No permission should appear in multiple categories.""" + seen = {} + for cat_name, perms in PERMISSION_CATEGORIES.items(): + for perm in perms: + assert perm not in seen, f"{perm} in both '{seen[perm]}' and '{cat_name}'" + seen[perm] = cat_name diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3a072159..2a0f81b31 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { FileManagerPage } from './pages/FileManagerPage'; import { CameraPage } from './pages/CameraPage'; import { StreamOverlayPage } from './pages/StreamOverlayPage'; import { ExternalLinkPage } from './pages/ExternalLinkPage'; +import { GroupEditPage } from './pages/GroupEditPage'; import InventoryPage from './pages/InventoryPage'; import { SystemInfoPage } from './pages/SystemInfoPage'; import { LoginPage } from './pages/LoginPage'; @@ -126,6 +127,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/__tests__/pages/GroupEditPage.test.tsx b/frontend/src/__tests__/pages/GroupEditPage.test.tsx new file mode 100644 index 000000000..d7b4d263a --- /dev/null +++ b/frontend/src/__tests__/pages/GroupEditPage.test.tsx @@ -0,0 +1,218 @@ +/** + * Tests for the GroupEditPage component. + * + * Covers create mode, edit mode, permission search/filtering, + * select all / clear all, and category-level toggles. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import { GroupEditPage } from '../../pages/GroupEditPage'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +const mockPermissions = { + categories: [ + { + name: 'Printers', + permissions: [ + { value: 'printers:read', label: 'Read Printers' }, + { value: 'printers:control', label: 'Control Printers' }, + { value: 'printers:clear_plate', label: 'Clear Plate' }, + ], + }, + { + name: 'Archives', + permissions: [ + { value: 'archives:read', label: 'Read Archives' }, + { value: 'archives:create', label: 'Create Archives' }, + ], + }, + ], + all_permissions: [ + 'printers:read', + 'printers:control', + 'printers:clear_plate', + 'archives:read', + 'archives:create', + ], +}; + +const mockGroup = { + id: 2, + name: 'Operators', + description: 'Control printers and manage content', + permissions: ['printers:read', 'printers:control', 'printers:clear_plate'], + is_system: true, + user_count: 3, + users: [{ id: 1, username: 'admin', is_active: true }], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +describe('GroupEditPage', () => { + beforeEach(() => { + server.use( + http.get('/api/v1/groups/permissions', () => { + return HttpResponse.json(mockPermissions); + }), + http.get('/api/v1/groups/:id', () => { + return HttpResponse.json(mockGroup); + }), + http.post('/api/v1/groups/', async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ + id: 10, + ...body, + is_system: false, + user_count: 0, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }); + }), + http.patch('/api/v1/groups/:id', async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ + ...mockGroup, + ...body, + }); + }) + ); + }); + + describe('create mode', () => { + it('renders create title when no id param', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Create Group')).toBeInTheDocument(); + }); + }); + + it('shows permission categories', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Printers')).toBeInTheDocument(); + }); + expect(screen.getByText('Archives')).toBeInTheDocument(); + }); + + it('shows individual permissions', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Read Printers')).toBeInTheDocument(); + }); + expect(screen.getByText('Control Printers')).toBeInTheDocument(); + expect(screen.getByText('Clear Plate')).toBeInTheDocument(); + expect(screen.getByText('Read Archives')).toBeInTheDocument(); + expect(screen.getByText('Create Archives')).toBeInTheDocument(); + }); + + it('shows 0 selected initially', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/0 selected/)).toBeInTheDocument(); + }); + }); + + it('shows save and cancel buttons', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + }); + + describe('permission interactions', () => { + it('toggles individual permission on click', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Read Printers')).toBeInTheDocument(); + }); + + const checkbox = screen.getByText('Read Printers').closest('label')!.querySelector('input')!; + await user.click(checkbox); + + await waitFor(() => { + expect(screen.getByText(/1 selected/)).toBeInTheDocument(); + }); + }); + + it('select all selects all permissions', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Select All')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Select All')); + + await waitFor(() => { + expect(screen.getByText(/5 selected/)).toBeInTheDocument(); + }); + }); + + it('clear all deselects all permissions', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Select All')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Select All')); + await waitFor(() => { + expect(screen.getByText(/5 selected/)).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Clear All')); + await waitFor(() => { + expect(screen.getByText(/0 selected/)).toBeInTheDocument(); + }); + }); + + it('filters permissions by search', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Read Printers')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search permissions...'); + await user.type(searchInput, 'Clear'); + + await waitFor(() => { + expect(screen.getByText('Clear Plate')).toBeInTheDocument(); + expect(screen.queryByText('Read Printers')).not.toBeInTheDocument(); + expect(screen.queryByText('Archives')).not.toBeInTheDocument(); + }); + }); + + it('shows no results message for empty search', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Read Printers')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search permissions...'); + await user.type(searchInput, 'zzzznonexistent'); + + await waitFor(() => { + expect(screen.getByText('No permissions match your search')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/GroupsPage.test.tsx b/frontend/src/__tests__/pages/GroupsPage.test.tsx deleted file mode 100644 index 78be18961..000000000 --- a/frontend/src/__tests__/pages/GroupsPage.test.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Tests for the GroupsPage component. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { waitFor } from '@testing-library/react'; -import { render } from '../utils'; -import { GroupsPage } from '../../pages/GroupsPage'; -import { http, HttpResponse } from 'msw'; -import { server } from '../mocks/server'; - -const mockGroups = [ - { - id: 1, - name: 'Administrators', - description: 'Full access to all features', - permissions: ['printers:read', 'printers:control', 'settings:read', 'settings:update', 'users:read', 'users:create'], - is_system: true, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }, - { - id: 2, - name: 'Operators', - description: 'Control printers and manage content', - permissions: ['printers:read', 'printers:control', 'archives:read', 'queue:read', 'queue:create'], - is_system: true, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }, - { - id: 3, - name: 'Viewers', - description: 'Read-only access', - permissions: ['printers:read', 'archives:read', 'queue:read'], - is_system: true, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - }, -]; - -const mockPermissions = { - 'Printers': ['printers:read', 'printers:create', 'printers:update', 'printers:delete', 'printers:control'], - 'Archives': ['archives:read', 'archives:create', 'archives:update', 'archives:delete'], - 'Queue': ['queue:read', 'queue:create', 'queue:update', 'queue:delete'], - 'Settings': ['settings:read', 'settings:update'], - 'Users': ['users:read', 'users:create', 'users:update', 'users:delete'], -}; - -describe('GroupsPage', () => { - beforeEach(() => { - server.use( - http.get('/api/v1/groups/', () => { - return HttpResponse.json(mockGroups); - }), - http.get('/api/v1/groups/permissions', () => { - return HttpResponse.json(mockPermissions); - }), - http.get('/api/v1/auth/status', () => { - return HttpResponse.json({ - auth_enabled: false, - requires_setup: false, - }); - }), - http.get('/api/v1/users/', () => { - return HttpResponse.json([]); - }) - ); - }); - - describe('rendering', () => { - it('renders the page', async () => { - render(); - - // Page should render without errors - await waitFor(() => { - expect(document.body).toBeInTheDocument(); - }); - }); - - it('renders group names from API', async () => { - render(); - - await waitFor(() => { - // Check that the groups are rendered - expect(document.body.textContent).toContain('Administrators'); - expect(document.body.textContent).toContain('Operators'); - expect(document.body.textContent).toContain('Viewers'); - }); - }); - - it('shows group descriptions', async () => { - render(); - - await waitFor(() => { - expect(document.body.textContent).toContain('Full access to all features'); - }); - }); - }); - - describe('API integration', () => { - it('fetches groups on mount', async () => { - let groupsFetched = false; - - server.use( - http.get('/api/v1/groups/', () => { - groupsFetched = true; - return HttpResponse.json(mockGroups); - }) - ); - - render(); - - await waitFor(() => { - expect(groupsFetched).toBe(true); - }); - }); - - it('fetches permissions on mount', async () => { - let permissionsFetched = false; - - server.use( - http.get('/api/v1/groups/permissions', () => { - permissionsFetched = true; - return HttpResponse.json(mockPermissions); - }) - ); - - render(); - - await waitFor(() => { - expect(permissionsFetched).toBe(true); - }); - }); - }); -}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 18f4526a1..2e31bd318 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1970,7 +1970,7 @@ export interface ExternalLinkUpdate { // Permission type - all available permissions export type Permission = - | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' + | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate' | 'archives:read' | 'archives:create' | 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all' | 'archives:reprint_own' | 'archives:reprint_all' diff --git a/frontend/src/components/PrinterQueueWidget.tsx b/frontend/src/components/PrinterQueueWidget.tsx index 265384176..c48174d3a 100644 --- a/frontend/src/components/PrinterQueueWidget.tsx +++ b/frontend/src/components/PrinterQueueWidget.tsx @@ -70,7 +70,7 @@ export function PrinterQueueWidget({ printerId, printerState, plateCleared }: Pr ) : ( +

+ {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 */} +
+ + +
+
+ ); +} diff --git a/frontend/src/pages/GroupsPage.tsx b/frontend/src/pages/GroupsPage.tsx deleted file mode 100644 index 4d60b3f09..000000000 --- a/frontend/src/pages/GroupsPage.tsx +++ /dev/null @@ -1,498 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useTranslation } from 'react-i18next'; -import { - X, - Plus, - Edit2, - Trash2, - Save, - Loader2, - Shield, - ArrowLeft, - Users, - Check, - ChevronDown, - ChevronRight, -} from 'lucide-react'; -import { api } from '../api/client'; -import type { Group, GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client'; -import { useAuth } from '../contexts/AuthContext'; -import { useToast } from '../contexts/ToastContext'; -import { Button } from '../components/Button'; -import { Card, CardContent, CardHeader } from '../components/Card'; -import { ConfirmModal } from '../components/ConfirmModal'; - -export function GroupsPage() { - const navigate = useNavigate(); - const { t } = useTranslation(); - const { hasPermission } = useAuth(); - const { showToast } = useToast(); - const queryClient = useQueryClient(); - - const [showCreateModal, setShowCreateModal] = useState(false); - const [editingGroup, setEditingGroup] = useState(null); - const [deleteGroupId, setDeleteGroupId] = useState(null); - const [formData, setFormData] = useState<{ - name: string; - description: string; - permissions: Permission[]; - }>({ - name: '', - description: '', - permissions: [], - }); - const [expandedCategories, setExpandedCategories] = useState>(new Set()); - - // Close modal on Escape key - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && (showCreateModal || editingGroup)) { - setShowCreateModal(false); - setEditingGroup(null); - resetForm(); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [showCreateModal, editingGroup]); - - const { data: groups = [], isLoading: groupsLoading } = useQuery({ - queryKey: ['groups'], - queryFn: () => api.getGroups(), - enabled: hasPermission('groups:read'), - }); - - const { data: permissionsData } = useQuery({ - queryKey: ['permissions'], - queryFn: () => api.getPermissions(), - enabled: hasPermission('groups:read'), - }); - - const createMutation = useMutation({ - mutationFn: (data: GroupCreate) => api.createGroup(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['groups'] }); - setShowCreateModal(false); - resetForm(); - showToast(t('groups.toast.created')); - }, - onError: (error: Error) => { - showToast(error.message, 'error'); - }, - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: number; data: GroupUpdate }) => api.updateGroup(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['groups'] }); - setEditingGroup(null); - resetForm(); - showToast(t('groups.toast.updated')); - }, - onError: (error: Error) => { - showToast(error.message, 'error'); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: (id: number) => api.deleteGroup(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['groups'] }); - showToast(t('groups.toast.deleted')); - }, - onError: (error: Error) => { - showToast(error.message, 'error'); - }, - }); - - const resetForm = () => { - setFormData({ name: '', description: '', permissions: [] }); - setExpandedCategories(new Set()); - }; - - const handleCreate = () => { - if (!formData.name.trim()) { - showToast(t('groups.toast.enterGroupName'), 'error'); - return; - } - createMutation.mutate({ - name: formData.name, - description: formData.description || undefined, - permissions: formData.permissions, - }); - }; - - const handleUpdate = () => { - if (!editingGroup) return; - if (!formData.name.trim()) { - showToast(t('groups.toast.enterGroupName'), 'error'); - return; - } - updateMutation.mutate({ - id: editingGroup.id, - data: { - name: formData.name !== editingGroup.name ? formData.name : undefined, - description: formData.description, - permissions: formData.permissions, - }, - }); - }; - - const handleDelete = (id: number) => { - setDeleteGroupId(id); - }; - - const startEdit = (group: Group) => { - setEditingGroup(group); - setFormData({ - name: group.name, - description: group.description || '', - permissions: group.permissions, - }); - // Expand categories that have selected permissions - const cats = new Set(); - permissionsData?.categories.forEach((cat) => { - if (cat.permissions.some((p) => group.permissions.includes(p.value))) { - cats.add(cat.name); - } - }); - setExpandedCategories(cats); - }; - - const toggleCategory = (categoryName: string) => { - setExpandedCategories((prev) => { - const next = new Set(prev); - if (next.has(categoryName)) { - next.delete(categoryName); - } else { - next.add(categoryName); - } - return next; - }); - }; - - const togglePermission = (permission: Permission) => { - setFormData((prev) => { - const permissions = prev.permissions.includes(permission) - ? prev.permissions.filter((p) => p !== permission) - : [...prev.permissions, permission]; - return { ...prev, permissions }; - }); - }; - - const toggleCategoryPermissions = (category: PermissionCategory, checked: boolean) => { - setFormData((prev) => { - const categoryPerms = category.permissions.map((p) => p.value); - const otherPerms = prev.permissions.filter((p) => !categoryPerms.includes(p)); - const permissions = checked ? [...otherPerms, ...categoryPerms] : otherPerms; - return { ...prev, permissions }; - }); - }; - - const isCategoryFullySelected = (category: PermissionCategory) => { - return category.permissions.every((p) => formData.permissions.includes(p.value)); - }; - - const isCategoryPartiallySelected = (category: PermissionCategory) => { - const selected = category.permissions.filter((p) => formData.permissions.includes(p.value)); - return selected.length > 0 && selected.length < category.permissions.length; - }; - - // Permission check - if (!hasPermission('groups:read')) { - return ( -
- - -
- -

{t('groups.noPermission')}

-
-
-
-
- ); - } - - const renderPermissionEditor = () => ( -
- {permissionsData?.categories.map((category) => ( -
-
toggleCategory(category.name)} - > -
- - {category.name} - - ({category.permissions.filter((p) => formData.permissions.includes(p.value)).length}/ - {category.permissions.length}) - -
- {expandedCategories.has(category.name) ? ( - - ) : ( - - )} -
- {expandedCategories.has(category.name) && ( -
- {category.permissions.map((perm) => ( - - ))} -
- )} -
- ))} -
- ); - - return ( -
-
-
- -
-

- - {t('groups.title')} -

-

- {t('groups.subtitle')} -

-
-
- {hasPermission('groups:create') && ( - - )} -
- - {groupsLoading ? ( -
- -
- ) : ( -
- {groups.map((group) => ( - - -
-
- -

{group.name}

- {group.is_system && ( - - {t('groups.system')} - - )} -
-
-
- -

{group.description || t('groups.noDescription')}

-
-
- - {t('groups.usersCount', { count: group.user_count })} -
-
- {t('groups.permissionsCount', { count: group.permissions.length })} -
-
-
- {hasPermission('groups:update') && ( - - )} - {hasPermission('groups:delete') && !group.is_system && ( - - )} -
-
-
- ))} -
- )} - - {/* Create/Edit Group Modal */} - {(showCreateModal || editingGroup) && ( -
{ - setShowCreateModal(false); - setEditingGroup(null); - resetForm(); - }} - > - e.stopPropagation()} - > - -
-
- -

- {editingGroup ? t('groups.modal.editGroup') : t('groups.modal.createGroup')} -

-
- -
-
- -
-
- - setFormData({ ...formData, name: e.target.value })} - disabled={editingGroup?.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')} - /> - {editingGroup?.is_system && ( -

{t('groups.form.systemGroupWarning')}

- )} -
-
- -