Browse Source

feat: add dedicated clear_plate permission + full-page group editor (#446)

Add a new `printers:clear_plate` permission so admins can grant
plate-clearing ability without full `printers:control` access.
Existing groups with `printers:control` automatically receive the
new permission on startup via migration.

Replace the cramped permission modal in Settings with a dedicated
full-page group editor (`/groups/new`, `/groups/:id/edit`) featuring
search filtering, select all/clear all, category-level toggles,
and a responsive 2-column permission grid. Remove dead GroupsPage
code that was never routed.

- Backend: new Permission enum, category, defaults, endpoint guard, migration
- Frontend: GroupEditPage, updated routes, removed modal from SettingsPage
- Tests: 10 backend + 10 frontend tests for new permission and editor
- Docs: updated wiki (auth, printer-control, API), website, changelog
- i18n: added group editor keys to all 6 locale files (en/de/ja/fr/pt-BR/it)
maziggy 3 months ago
parent
commit
df83250ff8

+ 4 - 0
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.
 

+ 1 - 1
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.

+ 13 - 0
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

+ 3 - 0
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,

+ 76 - 0
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

+ 3 - 0
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() {
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
+                  <Route path="groups/new" element={<AdminRoute><GroupEditPage /></AdminRoute>} />
+                  <Route path="groups/:id/edit" element={<AdminRoute><GroupEditPage /></AdminRoute>} />
                   <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="system" element={<SystemInfoPage />} />

+ 218 - 0
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<string, unknown>;
+        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<string, unknown>;
+        return HttpResponse.json({
+          ...mockGroup,
+          ...body,
+        });
+      })
+    );
+  });
+
+  describe('create mode', () => {
+    it('renders create title when no id param', async () => {
+      render(<GroupEditPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Create Group')).toBeInTheDocument();
+      });
+    });
+
+    it('shows permission categories', async () => {
+      render(<GroupEditPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printers')).toBeInTheDocument();
+      });
+      expect(screen.getByText('Archives')).toBeInTheDocument();
+    });
+
+    it('shows individual permissions', async () => {
+      render(<GroupEditPage />);
+
+      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(<GroupEditPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/0 selected/)).toBeInTheDocument();
+      });
+    });
+
+    it('shows save and cancel buttons', async () => {
+      render(<GroupEditPage />);
+
+      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(<GroupEditPage />);
+
+      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(<GroupEditPage />);
+
+      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(<GroupEditPage />);
+
+      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(<GroupEditPage />);
+
+      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(<GroupEditPage />);
+
+      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();
+      });
+    });
+  });
+});

+ 0 - 136
frontend/src/__tests__/pages/GroupsPage.test.tsx

@@ -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(<GroupsPage />);
-
-      // Page should render without errors
-      await waitFor(() => {
-        expect(document.body).toBeInTheDocument();
-      });
-    });
-
-    it('renders group names from API', async () => {
-      render(<GroupsPage />);
-
-      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(<GroupsPage />);
-
-      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(<GroupsPage />);
-
-      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(<GroupsPage />);
-
-      await waitFor(() => {
-        expect(permissionsFetched).toBe(true);
-      });
-    });
-  });
-});

+ 1 - 1
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'

+ 1 - 1
frontend/src/components/PrinterQueueWidget.tsx

@@ -70,7 +70,7 @@ export function PrinterQueueWidget({ printerId, printerState, plateCleared }: Pr
         ) : (
           <button
             onClick={() => clearPlateMutation.mutate()}
-            disabled={clearPlateMutation.isPending || !hasPermission('printers:control')}
+            disabled={clearPlateMutation.isPending || !hasPermission('printers:clear_plate')}
             className="w-full py-2 px-3 rounded-lg bg-bambu-green/20 border border-bambu-green/40 text-bambu-green hover:bg-bambu-green/30 transition-colors text-sm font-medium flex items-center justify-center gap-2 disabled:opacity-50"
           >
             {clearPlateMutation.isPending ? (

+ 9 - 0
frontend/src/i18n/locales/de.ts

@@ -1815,6 +1815,15 @@ export default {
       message: 'Sind Sie sicher, dass Sie diese Gruppe löschen möchten? Benutzer in dieser Gruppe verlieren diese Berechtigungen.',
       confirm: 'Gruppe löschen',
     },
+    editor: {
+      title: 'Gruppe bearbeiten',
+      createTitle: 'Gruppe erstellen',
+      search: 'Berechtigungen suchen...',
+      selectAll: 'Alle auswählen',
+      clearAll: 'Alle abwählen',
+      permissionsSelected: '{{count}} ausgewählt',
+      noResults: 'Keine Berechtigungen entsprechen Ihrer Suche',
+    },
   },
 
   // Users management

+ 9 - 0
frontend/src/i18n/locales/en.ts

@@ -1815,6 +1815,15 @@ export default {
       message: 'Are you sure you want to delete this group? Users in this group will lose these permissions.',
       confirm: 'Delete Group',
     },
+    editor: {
+      title: 'Edit Group',
+      createTitle: 'Create Group',
+      search: 'Search permissions...',
+      selectAll: 'Select All',
+      clearAll: 'Clear All',
+      permissionsSelected: '{{count}} selected',
+      noResults: 'No permissions match your search',
+    },
   },
 
   // Users management

+ 9 - 0
frontend/src/i18n/locales/fr.ts

@@ -1803,6 +1803,15 @@ export default {
       message: 'Les utilisateurs de ce groupe perdront ces permissions.',
       confirm: 'Supprimer',
     },
+    editor: {
+      title: 'Modifier le groupe',
+      createTitle: 'Créer un groupe',
+      search: 'Rechercher des permissions...',
+      selectAll: 'Tout sélectionner',
+      clearAll: 'Tout désélectionner',
+      permissionsSelected: '{{count}} sélectionnée(s)',
+      noResults: 'Aucune permission ne correspond à votre recherche',
+    },
   },
 
   // Users management

+ 9 - 0
frontend/src/i18n/locales/it.ts

@@ -1628,6 +1628,15 @@ export default {
       message: 'Sei sicuro di voler eliminare questo gruppo? Gli utenti in questo gruppo perderanno questi permessi.',
       confirm: 'Elimina gruppo',
     },
+    editor: {
+      title: 'Modifica gruppo',
+      createTitle: 'Crea gruppo',
+      search: 'Cerca permessi...',
+      selectAll: 'Seleziona tutto',
+      clearAll: 'Deseleziona tutto',
+      permissionsSelected: '{{count}} selezionati',
+      noResults: 'Nessun permesso corrisponde alla ricerca',
+    },
   },
 
   // Users management

+ 9 - 0
frontend/src/i18n/locales/ja.ts

@@ -1784,6 +1784,15 @@ export default {
       title: 'グループを削除',
       message: 'このグループを削除しますか?このグループのユーザーはこれらの権限を失います。',
     },
+    editor: {
+      title: 'グループを編集',
+      createTitle: 'グループを作成',
+      search: '権限を検索...',
+      selectAll: 'すべて選択',
+      clearAll: 'すべて解除',
+      permissionsSelected: '{{count}}件選択',
+      noResults: '検索に一致する権限がありません',
+    },
     title: 'グループ管理',
     subtitle: 'アクセス制御の権限グループを管理',
     system: 'システム',

+ 9 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1815,6 +1815,15 @@ export default {
       message: 'Tem certeza de que deseja excluir este grupo? Os usuários deste grupo perderão essas permissões.',
       confirm: 'Excluir Grupo',
     },
+    editor: {
+      title: 'Editar Grupo',
+      createTitle: 'Criar Grupo',
+      search: 'Pesquisar permissões...',
+      selectAll: 'Selecionar Tudo',
+      clearAll: 'Limpar Tudo',
+      permissionsSelected: '{{count}} selecionada(s)',
+      noResults: 'Nenhuma permissão corresponde à sua pesquisa',
+    },
   },
 
   // Users management

+ 309 - 0
frontend/src/pages/GroupEditPage.tsx

@@ -0,0 +1,309 @@
+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<Permission[]>([]);
+  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'] });
+      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: () => {
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
+      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 (
+      <div className="flex items-center justify-center py-16">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-6 max-w-5xl mx-auto">
+      {/* Header */}
+      <div className="flex items-center gap-3">
+        <button
+          onClick={() => navigate('/settings?tab=users')}
+          className="p-2 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+        >
+          <ArrowLeft className="w-5 h-5" />
+        </button>
+        <h1 className="text-xl font-bold text-white">
+          {isEditing ? t('groups.editor.title') : t('groups.editor.createTitle')}
+        </h1>
+      </div>
+
+      {/* System group warning */}
+      {isEditing && groupData?.is_system && (
+        <div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-sm">
+          <AlertTriangle className="w-4 h-4 shrink-0" />
+          {t('groups.form.systemGroupWarning')}
+        </div>
+      )}
+
+      {/* Name + Description */}
+      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+        <div>
+          <label className="block text-sm font-medium text-white mb-2">{t('groups.form.groupName')}</label>
+          <input
+            type="text"
+            value={name}
+            onChange={(e) => 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')}
+          />
+        </div>
+        <div>
+          <label className="block text-sm font-medium text-white mb-2">{t('groups.form.description')}</label>
+          <input
+            type="text"
+            value={description}
+            onChange={(e) => 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')}
+          />
+        </div>
+      </div>
+
+      {/* Toolbar */}
+      <div className="flex items-center justify-between flex-wrap gap-3">
+        <div className="flex items-center gap-3">
+          <span className="text-sm text-bambu-gray">
+            {t('groups.editor.permissionsSelected', { count: permissions.length })} / {totalPermissions}
+          </span>
+          <Button size="sm" variant="ghost" onClick={selectAll}>
+            {t('groups.editor.selectAll')}
+          </Button>
+          <Button size="sm" variant="ghost" onClick={clearAll}>
+            {t('groups.editor.clearAll')}
+          </Button>
+        </div>
+        <div className="relative">
+          <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray" />
+          <input
+            type="text"
+            value={search}
+            onChange={(e) => 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"
+          />
+        </div>
+      </div>
+
+      {/* Permission grid */}
+      {filteredCategories.length === 0 ? (
+        <div className="text-center py-12 text-bambu-gray">
+          {t('groups.editor.noResults')}
+        </div>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+          {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 (
+              <Card key={category.name}>
+                <div className="sticky top-0 z-10 flex items-center justify-between px-4 py-3 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary rounded-t-xl">
+                  <div className="flex items-center gap-3">
+                    <button
+                      type="button"
+                      onClick={() => toggleCategoryPermissions(fullCategory, !fullySelected)}
+                      className={`w-5 h-5 rounded border flex items-center justify-center transition-colors shrink-0 ${
+                        fullySelected
+                          ? 'bg-bambu-green border-bambu-green'
+                          : partiallySelected
+                          ? 'bg-bambu-green/50 border-bambu-green'
+                          : 'border-bambu-gray hover:border-white'
+                      }`}
+                    >
+                      {fullySelected && <Check className="w-3 h-3 text-white" />}
+                      {partiallySelected && !fullySelected && <Minus className="w-3 h-3 text-white" />}
+                    </button>
+                    <Shield className="w-4 h-4 text-bambu-gray shrink-0" />
+                    <span className="text-white font-medium text-sm">{category.name}</span>
+                  </div>
+                  <span className="text-xs text-bambu-gray tabular-nums">
+                    {selectedCount}/{totalCount}
+                  </span>
+                </div>
+                <div className="p-3 space-y-1">
+                  {category.permissions.map((perm) => (
+                    <label
+                      key={perm.value}
+                      className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer"
+                    >
+                      <input
+                        type="checkbox"
+                        checked={permissions.includes(perm.value)}
+                        onChange={() => togglePermission(perm.value)}
+                        className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
+                      />
+                      <span className="text-sm text-bambu-gray">{perm.label}</span>
+                    </label>
+                  ))}
+                </div>
+              </Card>
+            );
+          })}
+        </div>
+      )}
+
+      {/* Spacer for fixed bottom bar */}
+      <div className="h-16" />
+
+      {/* Fixed bottom bar */}
+      <div className="fixed bottom-0 left-0 right-0 z-20 px-6 py-3 bg-bambu-dark-secondary border-t border-bambu-dark-tertiary flex items-center justify-center gap-3">
+        <Button variant="secondary" onClick={() => navigate('/settings?tab=users')}>
+          {t('common.cancel')}
+        </Button>
+        <Button onClick={handleSave} disabled={isSaving || !name.trim()}>
+          {isSaving ? (
+            <>
+              <Loader2 className="w-4 h-4 animate-spin" />
+              {t('common.saving')}
+            </>
+          ) : (
+            <>
+              <Save className="w-4 h-4" />
+              {t('common.save')}
+            </>
+          )}
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 0 - 498
frontend/src/pages/GroupsPage.tsx

@@ -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<Group | null>(null);
-  const [deleteGroupId, setDeleteGroupId] = useState<number | null>(null);
-  const [formData, setFormData] = useState<{
-    name: string;
-    description: string;
-    permissions: Permission[];
-  }>({
-    name: '',
-    description: '',
-    permissions: [],
-  });
-  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(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<string>();
-    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 (
-      <div className="p-6">
-        <Card>
-          <CardContent className="py-6">
-            <div className="flex items-center gap-3 text-red-400">
-              <Shield className="w-5 h-5" />
-              <p className="text-white">{t('groups.noPermission')}</p>
-            </div>
-          </CardContent>
-        </Card>
-      </div>
-    );
-  }
-
-  const renderPermissionEditor = () => (
-    <div className="space-y-2 max-h-96 overflow-y-auto">
-      {permissionsData?.categories.map((category) => (
-        <div key={category.name} className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
-          <div
-            className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary cursor-pointer hover:bg-bambu-dark-tertiary transition-colors"
-            onClick={() => toggleCategory(category.name)}
-          >
-            <div className="flex items-center gap-3">
-              <button
-                type="button"
-                onClick={(e) => {
-                  e.stopPropagation();
-                  toggleCategoryPermissions(category, !isCategoryFullySelected(category));
-                }}
-                className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${
-                  isCategoryFullySelected(category)
-                    ? 'bg-bambu-green border-bambu-green'
-                    : isCategoryPartiallySelected(category)
-                    ? 'bg-bambu-green/50 border-bambu-green'
-                    : 'border-bambu-gray hover:border-white'
-                }`}
-              >
-                {(isCategoryFullySelected(category) || isCategoryPartiallySelected(category)) && (
-                  <Check className="w-3 h-3 text-white" />
-                )}
-              </button>
-              <span className="text-white font-medium">{category.name}</span>
-              <span className="text-xs text-bambu-gray">
-                ({category.permissions.filter((p) => formData.permissions.includes(p.value)).length}/
-                {category.permissions.length})
-              </span>
-            </div>
-            {expandedCategories.has(category.name) ? (
-              <ChevronDown className="w-4 h-4 text-bambu-gray" />
-            ) : (
-              <ChevronRight className="w-4 h-4 text-bambu-gray" />
-            )}
-          </div>
-          {expandedCategories.has(category.name) && (
-            <div className="p-3 bg-bambu-dark space-y-2">
-              {category.permissions.map((perm) => (
-                <label
-                  key={perm.value}
-                  className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer"
-                >
-                  <input
-                    type="checkbox"
-                    checked={formData.permissions.includes(perm.value)}
-                    onChange={() => togglePermission(perm.value)}
-                    className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
-                  />
-                  <span className="text-sm text-bambu-gray">{perm.label}</span>
-                </label>
-              ))}
-            </div>
-          )}
-        </div>
-      ))}
-    </div>
-  );
-
-  return (
-    <div className="p-6">
-      <div className="flex justify-between items-center mb-6">
-        <div className="flex items-center gap-4">
-          <button
-            onClick={() => navigate('/settings?tab=users')}
-            className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
-            title={t('groups.backToSettings')}
-          >
-            <ArrowLeft className="w-5 h-5" />
-          </button>
-          <div>
-            <h1 className="text-2xl font-bold text-white flex items-center gap-2">
-              <Shield className="w-6 h-6 text-bambu-green" />
-              {t('groups.title')}
-            </h1>
-            <p className="text-sm text-bambu-gray mt-1">
-              {t('groups.subtitle')}
-            </p>
-          </div>
-        </div>
-        {hasPermission('groups:create') && (
-          <Button
-            onClick={() => {
-              setShowCreateModal(true);
-              resetForm();
-            }}
-          >
-            <Plus className="w-4 h-4" />
-            {t('groups.createGroup')}
-          </Button>
-        )}
-      </div>
-
-      {groupsLoading ? (
-        <div className="flex items-center justify-center py-12">
-          <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
-        </div>
-      ) : (
-        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
-          {groups.map((group) => (
-            <Card key={group.id}>
-              <CardHeader>
-                <div className="flex items-center justify-between">
-                  <div className="flex items-center gap-2">
-                    <Shield
-                      className={`w-5 h-5 ${
-                        group.name === 'Administrators'
-                          ? 'text-purple-400'
-                          : group.name === 'Operators'
-                          ? 'text-blue-400'
-                          : group.name === 'Viewers'
-                          ? 'text-green-400'
-                          : 'text-bambu-gray'
-                      }`}
-                    />
-                    <h3 className="text-lg font-semibold text-white">{group.name}</h3>
-                    {group.is_system && (
-                      <span className="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
-                        {t('groups.system')}
-                      </span>
-                    )}
-                  </div>
-                </div>
-              </CardHeader>
-              <CardContent>
-                <p className="text-sm text-bambu-gray mb-4">{group.description || t('groups.noDescription')}</p>
-                <div className="flex items-center justify-between">
-                  <div className="flex items-center gap-2 text-sm text-bambu-gray">
-                    <Users className="w-4 h-4" />
-                    <span>{t('groups.usersCount', { count: group.user_count })}</span>
-                  </div>
-                  <div className="text-xs text-bambu-gray">
-                    {t('groups.permissionsCount', { count: group.permissions.length })}
-                  </div>
-                </div>
-                <div className="flex gap-2 mt-4 pt-4 border-t border-bambu-dark-tertiary">
-                  {hasPermission('groups:update') && (
-                    <Button size="sm" variant="ghost" onClick={() => startEdit(group)}>
-                      <Edit2 className="w-4 h-4" />
-                      {t('groups.edit')}
-                    </Button>
-                  )}
-                  {hasPermission('groups:delete') && !group.is_system && (
-                    <Button size="sm" variant="ghost" onClick={() => handleDelete(group.id)}>
-                      <Trash2 className="w-4 h-4" />
-                      {t('groups.delete')}
-                    </Button>
-                  )}
-                </div>
-              </CardContent>
-            </Card>
-          ))}
-        </div>
-      )}
-
-      {/* Create/Edit Group Modal */}
-      {(showCreateModal || editingGroup) && (
-        <div
-          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
-          onClick={() => {
-            setShowCreateModal(false);
-            setEditingGroup(null);
-            resetForm();
-          }}
-        >
-          <Card
-            className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
-            onClick={(e: React.MouseEvent) => e.stopPropagation()}
-          >
-            <CardHeader>
-              <div className="flex items-center justify-between">
-                <div className="flex items-center gap-2">
-                  <Shield className="w-5 h-5 text-bambu-green" />
-                  <h2 className="text-lg font-semibold text-white">
-                    {editingGroup ? t('groups.modal.editGroup') : t('groups.modal.createGroup')}
-                  </h2>
-                </div>
-                <Button
-                  variant="ghost"
-                  size="sm"
-                  onClick={() => {
-                    setShowCreateModal(false);
-                    setEditingGroup(null);
-                    resetForm();
-                  }}
-                >
-                  <X className="w-5 h-5" />
-                </Button>
-              </div>
-            </CardHeader>
-            <CardContent>
-              <div className="space-y-4">
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('groups.form.groupName')}
-                  </label>
-                  <input
-                    type="text"
-                    value={formData.name}
-                    onChange={(e) => 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 && (
-                    <p className="text-xs text-yellow-400 mt-1">{t('groups.form.systemGroupWarning')}</p>
-                  )}
-                </div>
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('groups.form.description')}
-                  </label>
-                  <textarea
-                    value={formData.description}
-                    onChange={(e) => setFormData({ ...formData, description: e.target.value })}
-                    rows={2}
-                    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 resize-none"
-                    placeholder={t('groups.form.descriptionPlaceholder')}
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    {t('groups.form.permissions', { count: formData.permissions.length })}
-                  </label>
-                  {renderPermissionEditor()}
-                </div>
-              </div>
-              <div className="mt-6 flex justify-end gap-3">
-                <Button
-                  variant="secondary"
-                  onClick={() => {
-                    setShowCreateModal(false);
-                    setEditingGroup(null);
-                    resetForm();
-                  }}
-                >
-                  {t('groups.modal.cancel')}
-                </Button>
-                <Button
-                  onClick={editingGroup ? handleUpdate : handleCreate}
-                  disabled={createMutation.isPending || updateMutation.isPending || !formData.name.trim()}
-                >
-                  {(createMutation.isPending || updateMutation.isPending) ? (
-                    <>
-                      <Loader2 className="w-4 h-4 animate-spin" />
-                      {editingGroup ? t('groups.modal.saving') : t('groups.modal.creating')}
-                    </>
-                  ) : (
-                    <>
-                      <Save className="w-4 h-4" />
-                      {editingGroup ? t('groups.modal.saveChanges') : t('groups.modal.createGroup')}
-                    </>
-                  )}
-                </Button>
-              </div>
-            </CardContent>
-          </Card>
-        </div>
-      )}
-
-      {/* Delete Confirmation Modal */}
-      {deleteGroupId !== null && (
-        <ConfirmModal
-          title={t('groups.deleteModal.title')}
-          message={t('groups.deleteModal.message')}
-          confirmText={t('groups.deleteModal.confirm')}
-          variant="danger"
-          onConfirm={() => {
-            deleteMutation.mutate(deleteGroupId);
-            setDeleteGroupId(null);
-          }}
-          onCancel={() => setDeleteGroupId(null)}
-        />
-      )}
-    </div>
-  );
-}

+ 4 - 299
frontend/src/pages/SettingsPage.tsx

@@ -1,12 +1,12 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, ChevronRight, Check, Save, Mail } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDateOnly } from '../utils/date';
 import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency';
-import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, Group, GroupCreate, GroupUpdate, Permission, PermissionCategory, StorageUsageResponse } from '../api/client';
+import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Button } from '../components/Button';
 import { SmartPlugCard } from '../components/SmartPlugCard';
@@ -158,19 +158,7 @@ export function SettingsPage() {
   });
 
   // Group management state
-  const [showCreateGroupModal, setShowCreateGroupModal] = useState(false);
-  const [editingGroup, setEditingGroup] = useState<Group | null>(null);
   const [deleteGroupId, setDeleteGroupId] = useState<number | null>(null);
-  const [groupFormData, setGroupFormData] = useState<{
-    name: string;
-    description: string;
-    permissions: Permission[];
-  }>({
-    name: '',
-    description: '',
-    permissions: [],
-  });
-  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
 
   // Home Assistant test connection state
   const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
@@ -402,12 +390,6 @@ export function SettingsPage() {
     enabled: authEnabled && hasPermission('groups:read'),
   });
 
-  const { data: permissionsData } = useQuery({
-    queryKey: ['permissions'],
-    queryFn: () => api.getPermissions(),
-    enabled: authEnabled && hasPermission('groups:read'),
-  });
-
   const createUserMutation = useMutation({
     mutationFn: (data: UserCreate) => api.createUser(data),
     onSuccess: () => {
@@ -475,32 +457,6 @@ export function SettingsPage() {
     }
   };
 
-  const createGroupMutation = useMutation({
-    mutationFn: (data: GroupCreate) => api.createGroup(data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['groups'] });
-      setShowCreateGroupModal(false);
-      resetGroupForm();
-      showToast(t('settings.toast.groupCreated'));
-    },
-    onError: (error: Error) => {
-      showToast(error.message, 'error');
-    },
-  });
-
-  const updateGroupMutation = useMutation({
-    mutationFn: ({ id, data }: { id: number; data: GroupUpdate }) => api.updateGroup(id, data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['groups'] });
-      setEditingGroup(null);
-      resetGroupForm();
-      showToast(t('settings.toast.groupUpdated'));
-    },
-    onError: (error: Error) => {
-      showToast(error.message, 'error');
-    },
-  });
-
   const deleteGroupMutation = useMutation({
     mutationFn: (id: number) => api.deleteGroup(id),
     onSuccess: () => {
@@ -598,95 +554,6 @@ export function SettingsPage() {
     }));
   };
 
-  // Group management handlers
-  const resetGroupForm = () => {
-    setGroupFormData({ name: '', description: '', permissions: [] });
-    setExpandedCategories(new Set());
-  };
-
-  const handleCreateGroup = () => {
-    if (!groupFormData.name.trim()) {
-      showToast(t('settings.toast.enterGroupName'), 'error');
-      return;
-    }
-    createGroupMutation.mutate({
-      name: groupFormData.name,
-      description: groupFormData.description || undefined,
-      permissions: groupFormData.permissions,
-    });
-  };
-
-  const handleUpdateGroup = () => {
-    if (!editingGroup) return;
-    if (!groupFormData.name.trim()) {
-      showToast(t('settings.toast.enterGroupName'), 'error');
-      return;
-    }
-    updateGroupMutation.mutate({
-      id: editingGroup.id,
-      data: {
-        name: groupFormData.name !== editingGroup.name ? groupFormData.name : undefined,
-        description: groupFormData.description,
-        permissions: groupFormData.permissions,
-      },
-    });
-  };
-
-  const startEditGroup = (group: Group) => {
-    setEditingGroup(group);
-    setGroupFormData({
-      name: group.name,
-      description: group.description || '',
-      permissions: group.permissions,
-    });
-    const cats = new Set<string>();
-    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) => {
-    setGroupFormData((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) => {
-    setGroupFormData((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) => groupFormData.permissions.includes(p.value));
-  };
-
-  const isCategoryPartiallySelected = (category: PermissionCategory) => {
-    const selected = category.permissions.filter((p) => groupFormData.permissions.includes(p.value));
-    return selected.length > 0 && selected.length < category.permissions.length;
-  };
-
   const applyUpdateMutation = useMutation({
     mutationFn: api.applyUpdate,
     onSuccess: (data) => {
@@ -3899,10 +3766,7 @@ export function SettingsPage() {
                       {hasPermission('groups:create') && (
                         <Button
                           size="sm"
-                          onClick={() => {
-                            setShowCreateGroupModal(true);
-                            resetGroupForm();
-                          }}
+                          onClick={() => navigate('/groups/new')}
                         >
                           <Plus className="w-4 h-4" />
                           {t('settings.addGroup')}
@@ -3943,7 +3807,7 @@ export function SettingsPage() {
                               </div>
                               <div className="flex items-center gap-1">
                                 {hasPermission('groups:update') && (
-                                  <Button size="sm" variant="ghost" onClick={() => startEditGroup(group)}>
+                                  <Button size="sm" variant="ghost" onClick={() => navigate(`/groups/${group.id}/edit`)}>
                                     <Edit2 className="w-4 h-4" />
                                   </Button>
                                 )}
@@ -4459,165 +4323,6 @@ export function SettingsPage() {
         </div>
       )}
 
-      {/* Create/Edit Group Modal */}
-      {(showCreateGroupModal || editingGroup) && (
-        <div
-          className="fixed inset-0 bg-black flex items-center justify-center z-50 p-4"
-          onClick={() => {
-            setShowCreateGroupModal(false);
-            setEditingGroup(null);
-            resetGroupForm();
-          }}
-        >
-          <Card
-            className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
-            onClick={(e: React.MouseEvent) => e.stopPropagation()}
-          >
-            <CardHeader>
-              <div className="flex items-center justify-between">
-                <div className="flex items-center gap-2">
-                  <Shield className="w-5 h-5 text-bambu-green" />
-                  <h2 className="text-lg font-semibold text-white">
-                    {editingGroup ? 'Edit Group' : 'Create Group'}
-                  </h2>
-                </div>
-                <Button
-                  variant="ghost"
-                  size="sm"
-                  onClick={() => {
-                    setShowCreateGroupModal(false);
-                    setEditingGroup(null);
-                    resetGroupForm();
-                  }}
-                >
-                  <X className="w-5 h-5" />
-                </Button>
-              </div>
-            </CardHeader>
-            <CardContent>
-              <div className="space-y-4">
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">{t('settings.groupName')}</label>
-                  <input
-                    type="text"
-                    value={groupFormData.name}
-                    onChange={(e) => setGroupFormData({ ...groupFormData, 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('settings.enterGroupName')}
-                  />
-                  {editingGroup?.is_system && (
-                    <p className="text-xs text-yellow-400 mt-1">{t('settings.systemGroupWarning')}</p>
-                  )}
-                </div>
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">Description</label>
-                  <textarea
-                    value={groupFormData.description}
-                    onChange={(e) => setGroupFormData({ ...groupFormData, description: e.target.value })}
-                    rows={2}
-                    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 resize-none"
-                    placeholder={t('settings.enterDescriptionOptional')}
-                  />
-                </div>
-                <div>
-                  <label className="block text-sm font-medium text-white mb-2">
-                    Permissions ({groupFormData.permissions.length} selected)
-                  </label>
-                  <div className="space-y-2 max-h-96 overflow-y-auto">
-                    {permissionsData?.categories.map((category) => (
-                      <div key={category.name} className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
-                        <div
-                          className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary cursor-pointer hover:bg-bambu-dark-tertiary transition-colors"
-                          onClick={() => toggleCategory(category.name)}
-                        >
-                          <div className="flex items-center gap-3">
-                            <button
-                              type="button"
-                              onClick={(e) => {
-                                e.stopPropagation();
-                                toggleCategoryPermissions(category, !isCategoryFullySelected(category));
-                              }}
-                              className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${
-                                isCategoryFullySelected(category)
-                                  ? 'bg-bambu-green border-bambu-green'
-                                  : isCategoryPartiallySelected(category)
-                                  ? 'bg-bambu-green/50 border-bambu-green'
-                                  : 'border-bambu-gray hover:border-white'
-                              }`}
-                            >
-                              {(isCategoryFullySelected(category) || isCategoryPartiallySelected(category)) && (
-                                <Check className="w-3 h-3 text-white" />
-                              )}
-                            </button>
-                            <span className="text-white font-medium">{category.name}</span>
-                            <span className="text-xs text-bambu-gray">
-                              ({category.permissions.filter((p) => groupFormData.permissions.includes(p.value)).length}/
-                              {category.permissions.length})
-                            </span>
-                          </div>
-                          {expandedCategories.has(category.name) ? (
-                            <ChevronDown className="w-4 h-4 text-bambu-gray" />
-                          ) : (
-                            <ChevronRight className="w-4 h-4 text-bambu-gray" />
-                          )}
-                        </div>
-                        {expandedCategories.has(category.name) && (
-                          <div className="p-3 bg-bambu-dark space-y-2">
-                            {category.permissions.map((perm) => (
-                              <label
-                                key={perm.value}
-                                className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer"
-                              >
-                                <input
-                                  type="checkbox"
-                                  checked={groupFormData.permissions.includes(perm.value)}
-                                  onChange={() => togglePermission(perm.value)}
-                                  className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
-                                />
-                                <span className="text-sm text-bambu-gray">{perm.label}</span>
-                              </label>
-                            ))}
-                          </div>
-                        )}
-                      </div>
-                    ))}
-                  </div>
-                </div>
-              </div>
-              <div className="mt-6 flex justify-end gap-3">
-                <Button
-                  variant="secondary"
-                  onClick={() => {
-                    setShowCreateGroupModal(false);
-                    setEditingGroup(null);
-                    resetGroupForm();
-                  }}
-                >
-                  Cancel
-                </Button>
-                <Button
-                  onClick={editingGroup ? handleUpdateGroup : handleCreateGroup}
-                  disabled={createGroupMutation.isPending || updateGroupMutation.isPending || !groupFormData.name.trim()}
-                >
-                  {(createGroupMutation.isPending || updateGroupMutation.isPending) ? (
-                    <>
-                      <Loader2 className="w-4 h-4 animate-spin" />
-                      {editingGroup ? 'Saving...' : 'Creating...'}
-                    </>
-                  ) : (
-                    <>
-                      <Save className="w-4 h-4" />
-                      {editingGroup ? 'Save Changes' : 'Create Group'}
-                    </>
-                  )}
-                </Button>
-              </div>
-            </CardContent>
-          </Card>
-        </div>
-      )}
-
       {/* Delete Group Confirmation Modal */}
       {deleteGroupId !== null && (
         <ConfirmModal

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-xSRa6JJA.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DqAJp9_u.js"></script>
+    <script type="module" crossorigin src="/assets/index-xSRa6JJA.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-EqFdfChN.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff