Просмотр исходного кода

fix(auth/ui): sidebar accepts granular *_read tiers for archives/queue/files (#1755)

  navPermissions in Layout.tsx gated three resources on the LEGACY *:read flag.
  Default Operators group is seeded with *_own only (and the migration map flips
  legacy → _own on existing groups), so non-admin users never held the legacy
  permission and the sidebar hid Archives / Queue / Files even though the
  underlying API accepted their requests. Reporter only spotted Files; same bug
  shape applied to Archives and Queue.

  Fix: navPermissions accepts Permission | Permission[]; the three affected
  resources list all three tiers. isHidden checks .some(hasPermission) for
  arrays. Permission type extended with the matching *_own / *_all variants —
  backend already shipped them, the TS type just didn't declare them.
maziggy 2 месяцев назад
Родитель
Сommit
804fe470fa

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 87 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -420,4 +420,91 @@ describe('Layout', () => {
       });
     });
   });
+
+  describe('Sidebar gate accepts granular read tiers (#1755)', () => {
+    // Default Operators group is seeded with `*:read_own` only — never the
+    // legacy `*:read`. Previously the sidebar gate checked the legacy alone,
+    // so Archives / Queue / Files were hidden from every non-admin even
+    // though the underlying API endpoints accepted their requests. These
+    // tests pin that the gate accepts ANY of the three tiers (legacy /
+    // _own / _all) for the three resources that ship granular variants.
+    const enableAuthWithUser = (permissions: string[]) => {
+      server.use(
+        http.get('/api/v1/auth/status', () =>
+          HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+        ),
+        http.get('/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 1,
+            username: 'tester',
+            role: 'user',
+            is_active: true,
+            is_admin: false,
+            groups: [{ id: 2, name: 'Operators' }],
+            permissions,
+            created_at: '2026-01-01T00:00:00Z',
+          }),
+        ),
+      );
+      window.localStorage.setItem('auth_token', 'test-token');
+    };
+
+    const sidebarLink = (href: string) =>
+      document.querySelector(`aside a[href="${href}"]`);
+
+    it('shows Files in the sidebar when the user only has library:read_own', async () => {
+      enableAuthWithUser(['library:read_own']);
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(document.querySelector('aside')).toBeInTheDocument();
+        expect(sidebarLink('/files')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Files in the sidebar when the user only has library:read_all', async () => {
+      enableAuthWithUser(['library:read_all']);
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(sidebarLink('/files')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Archives in the sidebar when the user only has archives:read_own', async () => {
+      enableAuthWithUser(['archives:read_own']);
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(sidebarLink('/archives')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Queue in the sidebar when the user only has queue:read_own', async () => {
+      enableAuthWithUser(['queue:read_own']);
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(sidebarLink('/queue')).toBeInTheDocument();
+      });
+    });
+
+    it('still hides Files when the user has none of the three read tiers', async () => {
+      enableAuthWithUser(['printers:read']);
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(document.querySelector('aside')).toBeInTheDocument();
+      });
+
+      expect(sidebarLink('/files')).toBeNull();
+      expect(sidebarLink('/archives')).toBeNull();
+      expect(sidebarLink('/queue')).toBeNull();
+    });
+  });
 });

+ 3 - 3
frontend/src/api/client.ts

@@ -2898,13 +2898,13 @@ 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:clear_plate'
-  | 'archives:read' | 'archives:create'
+  | 'archives:read' | 'archives:read_own' | 'archives:read_all' | 'archives:create'
   | 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all'
   | 'archives:reprint_own' | 'archives:reprint_all' | 'archives:purge'
-  | 'queue:read' | 'queue:create'
+  | 'queue:read' | 'queue:read_own' | 'queue:read_all' | 'queue:create'
   | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
   | 'queue:reorder'
-  | 'library:read' | 'library:upload'
+  | 'library:read' | 'library:read_own' | 'library:read_all' | 'library:upload'
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'library:purge'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'

+ 17 - 6
frontend/src/components/Layout.tsx

@@ -279,23 +279,34 @@ export function Layout() {
     const result: string[] = [];
     const seen = new Set<string>();
 
-    // Map nav item IDs to the permission required to see them
-    const navPermissions: Record<string, Permission> = {
-      archives: 'archives:read',
-      queue: 'queue:read',
+    // Map nav item IDs to the permission(s) required to see them. Resources
+    // that ship in three tiers (legacy `*:read` + granular `*:read_own` /
+    // `*:read_all`) list all three: the default Operators group is seeded
+    // with `_own` only, so gating on the legacy alone hides the entry from
+    // every non-admin user even though the underlying API accepts their
+    // request (#1755).
+    const navPermissions: Record<string, Permission | Permission[]> = {
+      archives: ['archives:read', 'archives:read_own', 'archives:read_all'],
+      queue: ['queue:read', 'queue:read_own', 'queue:read_all'],
       stats: 'stats:read',
       profiles: 'kprofiles:read',
       maintenance: 'maintenance:read',
       projects: 'projects:read',
       inventory: 'inventory:read',
-      files: 'library:read',
+      files: ['library:read', 'library:read_own', 'library:read_all'],
       makerworld: 'makerworld:view',
       settings: 'settings:read',
       notifications: 'notifications:user_email',
     };
 
     const isHidden = (id: string) => {
-      if (authEnabled && id in navPermissions && !hasPermission(navPermissions[id])) return true;
+      if (authEnabled && id in navPermissions) {
+        const required = navPermissions[id];
+        const granted = Array.isArray(required)
+          ? required.some((p) => hasPermission(p))
+          : hasPermission(required);
+        if (!granted) return true;
+      }
       // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting
       if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true;
       return false;

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BMUh7cW4.js


+ 1 - 1
static/index.html

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

Некоторые файлы не были показаны из-за большого количества измененных файлов