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

fix(permissions): hide MakerWorld nav entry from users without makerworld:view (#1175)

  Backend routes were already gated on makerworld:view, the permission
  was granted to admin + standard-user role defaults, and the frontend
  Permission type union already included 'makerworld:view' — but the
  sidebar's hand-maintained navPermissions map in Layout.tsx had no
  entry for `makerworld`. So `isHidden('makerworld')` always returned
  false, the entry rendered for every authenticated user regardless
  of group permissions, and the only way the user found out they
  couldn't use it was by clicking and getting 403'd by every API call.

  Fix is two lines:

  - Layout.tsx: add `makerworld: 'makerworld:view'` to navPermissions,
    matching every other sidebar entry's gating shape.
  - App.tsx: wrap the /makerworld route in PermissionRoute for defence
    in depth, so a user who knows the URL can no longer reach the page
    directly. Same pattern already used by settings, groups/new, and
    groups/:id/edit two lines below.

  Two new Layout tests pin the contract: with auth enabled and a user
  lacking makerworld:view, the sidebar <a href="/makerworld"> link is
  absent while other links still render; with the permission granted,
  the link renders.
maziggy 4 месяцев назад
Родитель
Сommit
32b5c42dfb

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


+ 1 - 1
frontend/src/App.tsx

@@ -197,7 +197,7 @@ function App() {
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="inventory" element={<InventoryPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="files/trash" element={<LibraryTrashPage />} />
                   <Route path="files/trash" element={<LibraryTrashPage />} />
-                  <Route path="makerworld" element={<MakerworldPage />} />
+                  <Route path="makerworld" element={<PermissionRoute permission="makerworld:view"><MakerworldPage /></PermissionRoute>} />
                   <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
                   <Route path="settings" element={<PermissionRoute permission="settings:read"><SettingsPage /></PermissionRoute>} />
                   <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
                   <Route path="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
                   <Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />
                   <Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />

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

@@ -305,4 +305,78 @@ describe('Layout', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('MakerWorld sidebar permission gate (#1175)', () => {
+    // The MakerWorld sidebar entry was visible to every authenticated user
+    // regardless of group permissions because Layout's `navPermissions` map
+    // had no entry for `makerworld`. Backend routes already gated on
+    // `makerworld:view`, so users without the permission saw the entry,
+    // clicked, and got 403'd by every API call inside the page. The fix
+    // adds `makerworld: 'makerworld:view'` to the map so the entry is
+    // hidden when the permission is absent — same shape as every other
+    // sidebar entry.
+    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: 'Standard Users' }],
+            permissions,
+            created_at: '2026-01-01T00:00:00Z',
+          }),
+        ),
+      );
+      // AuthProvider needs a token in localStorage to fetch /auth/me; the
+      // value isn't validated by the mocked server.
+      window.localStorage.setItem('auth_token', 'test-token');
+    };
+
+    const findMakerWorldNavLink = () => {
+      // Sidebar nav links use react-router's `to` prop, which renders as a
+      // plain `<a href="/makerworld">`. Match on the href so the test isn't
+      // coupled to whatever locale string is rendered.
+      return document.querySelector('aside a[href="/makerworld"]');
+    };
+
+    it('hides the MakerWorld nav entry when the user lacks makerworld:view', async () => {
+      // Standard user without the MakerWorld permission. Every other
+      // permission they hold (library:read, etc.) is irrelevant here — the
+      // gate is per-entry and the MakerWorld entry must not render.
+      enableAuthWithUser(['library:read', 'archives:read', 'queue:read']);
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        // Wait for the auth resolution + sidebar render. Some other nav
+        // entry (Files / Archives) confirms the sidebar finished mounting.
+        const sidebar = document.querySelector('aside');
+        expect(sidebar).toBeInTheDocument();
+        expect(sidebar?.querySelector('a[href="/files"]')).toBeInTheDocument();
+      });
+
+      expect(findMakerWorldNavLink()).toBeNull();
+    });
+
+    it('shows the MakerWorld nav entry when the user has makerworld:view', async () => {
+      enableAuthWithUser([
+        'library:read',
+        'archives:read',
+        'queue:read',
+        'makerworld:view',
+      ]);
+
+      render(<Layout />);
+
+      await waitFor(() => {
+        expect(findMakerWorldNavLink()).toBeInTheDocument();
+      });
+    });
+  });
 });
 });

+ 1 - 0
frontend/src/components/Layout.tsx

@@ -284,6 +284,7 @@ export function Layout() {
       projects: 'projects:read',
       projects: 'projects:read',
       inventory: 'inventory:read',
       inventory: 'inventory:read',
       files: 'library:read',
       files: 'library:read',
+      makerworld: 'makerworld:view',
       settings: 'settings:read',
       settings: 'settings:read',
       notifications: 'notifications:user_email',
       notifications: 'notifications:user_email',
     };
     };

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


+ 1 - 1
static/index.html

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

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