mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 22:21:29 +02:00
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.
This commit is contained in:
@@ -10,6 +10,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`.
|
||||
|
||||
### Fixed
|
||||
- **MakerWorld sidebar entry visible to every user regardless of group permissions** ([#1175](https://github.com/maziggy/bambuddy/issues/1175)) — Backend already enforced `makerworld:view` on every `/makerworld/*` route (`backend/app/api/routes/makerworld.py:145, 157, 242, 406`), the permission was correctly granted to the admin and standard-user role defaults (`permissions.py:298, 364, 454`), and the frontend `Permission` type union already included `'makerworld:view' | 'makerworld:import'` (`client.ts:2498`) — but the sidebar's hand-maintained `navPermissions` map in `Layout.tsx:278` had no entry for `makerworld`, so `isHidden('makerworld')` always returned false and the entry rendered for every authenticated user. Users without the permission saw the entry, clicked, and the page rendered while every API call inside it 403'd. Two-line fix: (1) `Layout.tsx:278` — add `makerworld: 'makerworld:view'` to the map, matching every other sidebar entry's gating shape; (2) `App.tsx:200` — wrap the route in `<PermissionRoute permission="makerworld:view">` for defence in depth, so a user who knows the URL can no longer reach the page directly (matches the existing pattern on `settings`, `groups/new`, `groups/:id/edit` two lines below). 2 new Layout tests pin the contract: with auth enabled and a user lacking `makerworld:view`, the sidebar `<a href="/makerworld">` link is absent (other links like `/files` still render); with the permission granted, the link renders.
|
||||
|
||||
- **Printer Info modal: serial-number and IP-address copy buttons silently did nothing on plain-HTTP LAN deployments** ([#1174](https://github.com/maziggy/bambuddy/issues/1174), reported by @BurntOutHylian) — `PrinterInfoModal`'s `CopyButton` only tried `navigator.clipboard.writeText()`, which is gated by the secure-context requirement (HTTPS or localhost). On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN — `navigator.clipboard` is undefined; the existing `try/catch` swallowed the resulting `TypeError`, the icon never flipped to the tick, and nothing landed on the user's clipboard. Fixed by adding the same off-screen-textarea + `document.execCommand('copy')` fallback that `CameraTokensPage`'s plaintext-token modal already uses for plain-HTTP LAN deployments: gate on `navigator.clipboard && window.isSecureContext`, fall back to the legacy path otherwise, and surface the success-tick only when the copy actually landed (return early without flipping `copied` if `execCommand('copy')` returns false). The `try/finally` around the textarea guarantees DOM cleanup even when the browser throws on a restricted context. 3 new component tests in `PrinterInfoModal.test.tsx` cover (a) secure-context happy path uses `navigator.clipboard.writeText`, (b) plain-HTTP fallback path actually invokes `execCommand('copy')` and leaves no leaked textarea in the DOM, (c) `finally` cleanup removes the textarea even when `execCommand` throws synthetically. Thanks to @BurntOutHylian for the precise file/line pointer in the report.
|
||||
|
||||
- **Queue auto-dispatched the next print onto a fouled bed after an aborted or cancelled print** ([#1171](https://github.com/maziggy/bambuddy/issues/1171), reported by @tom5677) — When a print ended with status `aborted` (printer self-abort, or a user stopping the print on the printer's own touchscreen) or `cancelled` (user stopping the print via the Bambuddy queue UI), the plate-clear gate added in [#961](https://github.com/maziggy/bambuddy/issues/961) was *not* raised — only `completed` and `failed` triggered it (`backend/app/main.py:2660`). Result: the queue scheduler dispatched the next pending item ~2 seconds after the abort, with the previous print's material still on the bed. The reporter saw two prints (P1P + P1S) auto-start onto fouled beds within seconds of each other after touchscreen-aborts, and explicitly flagged the risk of damage to the printer; a third printer (his second P1S) behaved correctly because its previous print had ended `completed`. The original code's comment ("user-cancelled prints don't require a plate-clear ack — nothing printed on the bed") only holds if you cancel right at layer 1; cancelling a 12-hour print at hour 11 leaves a fouled bed too. Fix: the gate is now raised for every terminal status — `completed`, `failed`, `aborted`, `cancelled` — matching the safety contract that the user must acknowledge the bed is clear before any next queued print starts. The gate is user-clearable on the Printers page, so worst case for a layer-1 cancel the user clicks "Clear Plate" once. Touchscreen-aborts are particularly important to gate because Bambuddy's "user stopped via UI" override (`_user_stopped_printers` → `aborted` mapped to `cancelled`) only fires when the user stops via the Bambuddy queue; a touchscreen-stop reports `aborted` straight through. Regression coverage in `test_print_lifecycle.py::TestPlateClearGate`: parametrised across all four terminal statuses (asserts `set_awaiting_plate_clear(printer_id, True)` is called for each), plus a defence-in-depth test that an unrecognised future status string never silently raises the gate.
|
||||
|
||||
@@ -197,7 +197,7 @@ function App() {
|
||||
<Route path="inventory" element={<InventoryPage />} />
|
||||
<Route path="files" element={<FileManagerPage />} />
|
||||
<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="groups/new" element={<PermissionRoute permission="groups:create"><GroupEditPage /></PermissionRoute>} />
|
||||
<Route path="groups/:id/edit" element={<PermissionRoute permission="groups:update"><GroupEditPage /></PermissionRoute>} />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,6 +284,7 @@ export function Layout() {
|
||||
projects: 'projects:read',
|
||||
inventory: 'inventory:read',
|
||||
files: 'library:read',
|
||||
makerworld: 'makerworld:view',
|
||||
settings: 'settings:read',
|
||||
notifications: 'notifications:user_email',
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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-BM5VeuBp.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BeGsSdpN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user