Add inventory:view_assignments permission so admins can grant users

visibility into AMS spool assignments on printer cards without exposing
  the full Inventory page (#635). The list_assignments endpoint now
  requires this new permission instead of inventory:read. All default
  groups (Administrators, Operators, Viewers) include it for backward
  compatibility.

  Closes #634
This commit is contained in:
maziggy
2026-03-07 09:17:26 +01:00
parent 60b17ea5d8
commit f66bd26f6e
11 changed files with 66 additions and 9 deletions
+6
View File
@@ -4,6 +4,12 @@ All notable changes to Bambuddy will be documented in this file.
## [0.2.2b3] - Unreleased
### New Features
- **Malaysian Ringgit Currency** ([#634](https://github.com/maziggy/bambuddy/issues/634)) — Added MYR (RM) to the list of supported currencies for filament cost tracking. Requested by @cynogen127.
### Improved
- **Separate Permission for AMS Spool Assignments** ([#635](https://github.com/maziggy/bambuddy/issues/635)) — Added a new `inventory:view_assignments` permission that controls whether spool-to-AMS-slot assignment data is visible on the Printers page. Previously, viewing spool assignments on printer cards required `inventory:read`, which also exposed the full Inventory page in the sidebar. Admins can now grant `inventory:view_assignments` without `inventory:read` so users can see what's loaded in the AMS without accessing the full spool inventory. All default groups (Administrators, Operators, Viewers) include the new permission automatically. Also fixed multi-word permission labels in the group editor (e.g. "Update_Own" → "Update Own"). Reported by @Minebuddy.
### Fixed
- **Dispatch Toast Stuck After Second Print** — The print dispatch progress toast ("Starting prints…") stayed visible forever after the second print dispatch in a session. The dedup guard (`lastDispatchSummaryRef`) that prevents duplicate completion toasts was never reset between batches, so every single-printer dispatch produced the same summary key (`"first-complete:1:0"`). The first print completed normally, but subsequent completions matched the stale ref and skipped creating the done toast — leaving the progress toast stuck in "Processing" state with no way to dismiss except a page reload. Now resets the dedup guard whenever the dispatch toast is dismissed (auto-dismiss timeout, cleanup events) and when a new batch starts.
+1 -1
View File
@@ -196,7 +196,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
### 🔒 Optional Authentication
- Enable/disable authentication any time
- Group-based permissions (50+ granular permissions)
- Group-based permissions (80+ granular permissions)
- Default groups: Administrators, Operators, Viewers
- JWT tokens with secure password hashing
- Comprehensive API protection (200+ endpoints secured)
+1 -1
View File
@@ -35,7 +35,7 @@ def _permission_label(perm: Permission) -> str:
if len(parts) == 2:
resource, action = parts
resource = resource.replace("_", " ").title()
action = action.title()
action = action.replace("_", " ").title()
return f"{action} {resource}"
return perm.value
+1 -1
View File
@@ -636,7 +636,7 @@ async def replace_k_profiles(
async def list_assignments(
printer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
):
"""List spool assignments, optionally filtered by printer."""
from backend.app.services.printer_manager import printer_manager
+4
View File
@@ -68,6 +68,7 @@ class Permission(StrEnum):
INVENTORY_CREATE = "inventory:create"
INVENTORY_UPDATE = "inventory:update"
INVENTORY_DELETE = "inventory:delete"
INVENTORY_VIEW_ASSIGNMENTS = "inventory:view_assignments" # View spool-to-AMS assignments on printer cards
# Smart Plugs
SMART_PLUGS_READ = "smart_plugs:read"
@@ -214,6 +215,7 @@ PERMISSION_CATEGORIES = {
Permission.INVENTORY_CREATE,
Permission.INVENTORY_UPDATE,
Permission.INVENTORY_DELETE,
Permission.INVENTORY_VIEW_ASSIGNMENTS,
],
"Smart Plugs": [
Permission.SMART_PLUGS_READ,
@@ -355,6 +357,7 @@ DEFAULT_GROUPS = {
Permission.INVENTORY_CREATE.value,
Permission.INVENTORY_UPDATE.value,
Permission.INVENTORY_DELETE.value,
Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
# Smart Plugs - full access
Permission.SMART_PLUGS_READ.value,
Permission.SMART_PLUGS_CREATE.value,
@@ -411,6 +414,7 @@ DEFAULT_GROUPS = {
Permission.PROJECTS_READ.value,
Permission.FILAMENTS_READ.value,
Permission.INVENTORY_READ.value,
Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
Permission.SMART_PLUGS_READ.value,
Permission.CAMERA_VIEW.value,
Permission.MAINTENANCE_READ.value,
+38
View File
@@ -74,3 +74,41 @@ class TestPermissionCategoriesCompleteness:
for perm in perms:
assert perm not in seen, f"{perm} in both '{seen[perm]}' and '{cat_name}'"
seen[perm] = cat_name
class TestInventoryViewAssignmentsPermission:
"""Test the INVENTORY_VIEW_ASSIGNMENTS permission."""
def test_view_assignments_permission_exists(self):
"""inventory:view_assignments permission should exist in the enum."""
assert hasattr(Permission, "INVENTORY_VIEW_ASSIGNMENTS")
assert Permission.INVENTORY_VIEW_ASSIGNMENTS == "inventory:view_assignments"
def test_view_assignments_in_all_permissions(self):
"""inventory:view_assignments should be in ALL_PERMISSIONS list."""
assert "inventory:view_assignments" in ALL_PERMISSIONS
def test_view_assignments_in_inventory_category(self):
"""inventory:view_assignments should be in the Inventory permission category."""
inventory_perms = PERMISSION_CATEGORIES["Inventory"]
assert Permission.INVENTORY_VIEW_ASSIGNMENTS in inventory_perms
def test_view_assignments_separate_from_read(self):
"""view_assignments and read should be distinct permissions."""
assert Permission.INVENTORY_VIEW_ASSIGNMENTS != Permission.INVENTORY_READ
assert Permission.INVENTORY_VIEW_ASSIGNMENTS.value != Permission.INVENTORY_READ.value
def test_operators_have_view_assignments(self):
"""Operators group should include inventory:view_assignments."""
operators = DEFAULT_GROUPS["Operators"]
assert "inventory:view_assignments" in operators["permissions"]
def test_viewers_have_view_assignments(self):
"""Viewers group should include inventory:view_assignments."""
viewers = DEFAULT_GROUPS["Viewers"]
assert "inventory:view_assignments" in viewers["permissions"]
def test_administrators_have_view_assignments(self):
"""Administrators should have all permissions including view_assignments."""
admins = DEFAULT_GROUPS["Administrators"]
assert "inventory:view_assignments" in admins["permissions"]
+10 -2
View File
@@ -22,6 +22,10 @@ describe('getCurrencySymbol', () => {
expect(getCurrencySymbol('HKD')).toBe('HK$');
});
it('returns RM for MYR', () => {
expect(getCurrencySymbol('MYR')).toBe('RM');
});
it('returns the code itself for unknown currencies', () => {
expect(getCurrencySymbol('XYZ')).toBe('XYZ');
});
@@ -37,7 +41,11 @@ describe('SUPPORTED_CURRENCIES', () => {
expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'INR')).toBeDefined();
});
it('has 26 entries', () => {
expect(SUPPORTED_CURRENCIES).toHaveLength(26);
it('contains MYR', () => {
expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'MYR')).toBeDefined();
});
it('has 27 entries', () => {
expect(SUPPORTED_CURRENCIES).toHaveLength(27);
});
});
+1 -1
View File
@@ -2023,7 +2023,7 @@ export type Permission =
| 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
| 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
| 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
| 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete'
| 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete' | 'inventory:view_assignments'
| 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
| 'camera:view'
| 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
+2 -1
View File
@@ -1337,7 +1337,7 @@ export function AmsNameHoverCard({
});
}
}, [isVisible, amsLabels, ams.id]);
const handleMouseEnter = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
@@ -5368,6 +5368,7 @@ export function PrintersPage() {
const { data: spoolAssignments } = useQuery({
queryKey: ['spool-assignments'],
queryFn: () => api.getAssignments(),
enabled: hasPermission('inventory:view_assignments'),
staleTime: 30 * 1000,
});
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-DCHZw0ab.js"></script>
<script type="module" crossorigin src="/assets/index-BpZJje_K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DfcIVNpM.css">
</head>
<body>