Procházet zdrojové kódy

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

maziggy před 2 měsíci
rodič
revize
f66bd26f6e

+ 6 - 0
CHANGELOG.md

@@ -4,6 +4,12 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 ## [0.2.2b3] - Unreleased
 ## [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
 ### 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.
 - **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
README.md

@@ -196,7 +196,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 
 
 ### 🔒 Optional Authentication
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
 - Enable/disable authentication any time
-- Group-based permissions (50+ granular permissions)
+- Group-based permissions (80+ granular permissions)
 - Default groups: Administrators, Operators, Viewers
 - Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
 - JWT tokens with secure password hashing
 - Comprehensive API protection (200+ endpoints secured)
 - Comprehensive API protection (200+ endpoints secured)

+ 1 - 1
backend/app/api/routes/groups.py

@@ -35,7 +35,7 @@ def _permission_label(perm: Permission) -> str:
     if len(parts) == 2:
     if len(parts) == 2:
         resource, action = parts
         resource, action = parts
         resource = resource.replace("_", " ").title()
         resource = resource.replace("_", " ").title()
-        action = action.title()
+        action = action.replace("_", " ").title()
         return f"{action} {resource}"
         return f"{action} {resource}"
     return perm.value
     return perm.value
 
 

+ 1 - 1
backend/app/api/routes/inventory.py

@@ -636,7 +636,7 @@ async def replace_k_profiles(
 async def list_assignments(
 async def list_assignments(
     printer_id: int | None = None,
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     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."""
     """List spool assignments, optionally filtered by printer."""
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.printer_manager import printer_manager

+ 4 - 0
backend/app/core/permissions.py

@@ -68,6 +68,7 @@ class Permission(StrEnum):
     INVENTORY_CREATE = "inventory:create"
     INVENTORY_CREATE = "inventory:create"
     INVENTORY_UPDATE = "inventory:update"
     INVENTORY_UPDATE = "inventory:update"
     INVENTORY_DELETE = "inventory:delete"
     INVENTORY_DELETE = "inventory:delete"
+    INVENTORY_VIEW_ASSIGNMENTS = "inventory:view_assignments"  # View spool-to-AMS assignments on printer cards
 
 
     # Smart Plugs
     # Smart Plugs
     SMART_PLUGS_READ = "smart_plugs:read"
     SMART_PLUGS_READ = "smart_plugs:read"
@@ -214,6 +215,7 @@ PERMISSION_CATEGORIES = {
         Permission.INVENTORY_CREATE,
         Permission.INVENTORY_CREATE,
         Permission.INVENTORY_UPDATE,
         Permission.INVENTORY_UPDATE,
         Permission.INVENTORY_DELETE,
         Permission.INVENTORY_DELETE,
+        Permission.INVENTORY_VIEW_ASSIGNMENTS,
     ],
     ],
     "Smart Plugs": [
     "Smart Plugs": [
         Permission.SMART_PLUGS_READ,
         Permission.SMART_PLUGS_READ,
@@ -355,6 +357,7 @@ DEFAULT_GROUPS = {
             Permission.INVENTORY_CREATE.value,
             Permission.INVENTORY_CREATE.value,
             Permission.INVENTORY_UPDATE.value,
             Permission.INVENTORY_UPDATE.value,
             Permission.INVENTORY_DELETE.value,
             Permission.INVENTORY_DELETE.value,
+            Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
             # Smart Plugs - full access
             # Smart Plugs - full access
             Permission.SMART_PLUGS_READ.value,
             Permission.SMART_PLUGS_READ.value,
             Permission.SMART_PLUGS_CREATE.value,
             Permission.SMART_PLUGS_CREATE.value,
@@ -411,6 +414,7 @@ DEFAULT_GROUPS = {
             Permission.PROJECTS_READ.value,
             Permission.PROJECTS_READ.value,
             Permission.FILAMENTS_READ.value,
             Permission.FILAMENTS_READ.value,
             Permission.INVENTORY_READ.value,
             Permission.INVENTORY_READ.value,
+            Permission.INVENTORY_VIEW_ASSIGNMENTS.value,
             Permission.SMART_PLUGS_READ.value,
             Permission.SMART_PLUGS_READ.value,
             Permission.CAMERA_VIEW.value,
             Permission.CAMERA_VIEW.value,
             Permission.MAINTENANCE_READ.value,
             Permission.MAINTENANCE_READ.value,

+ 38 - 0
backend/tests/unit/test_permissions.py

@@ -74,3 +74,41 @@ class TestPermissionCategoriesCompleteness:
             for perm in perms:
             for perm in perms:
                 assert perm not in seen, f"{perm} in both '{seen[perm]}' and '{cat_name}'"
                 assert perm not in seen, f"{perm} in both '{seen[perm]}' and '{cat_name}'"
                 seen[perm] = 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
frontend/src/__tests__/utils/currency.test.ts

@@ -22,6 +22,10 @@ describe('getCurrencySymbol', () => {
     expect(getCurrencySymbol('HKD')).toBe('HK$');
     expect(getCurrencySymbol('HKD')).toBe('HK$');
   });
   });
 
 
+  it('returns RM for MYR', () => {
+    expect(getCurrencySymbol('MYR')).toBe('RM');
+  });
+
   it('returns the code itself for unknown currencies', () => {
   it('returns the code itself for unknown currencies', () => {
     expect(getCurrencySymbol('XYZ')).toBe('XYZ');
     expect(getCurrencySymbol('XYZ')).toBe('XYZ');
   });
   });
@@ -37,7 +41,11 @@ describe('SUPPORTED_CURRENCIES', () => {
     expect(SUPPORTED_CURRENCIES.find((c) => c.code === 'INR')).toBeDefined();
     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
frontend/src/api/client.ts

@@ -2023,7 +2023,7 @@ export type Permission =
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
   | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments: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'
   | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
   | 'camera:view'
   | 'camera:view'
   | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
   | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'

+ 2 - 1
frontend/src/pages/PrintersPage.tsx

@@ -1337,7 +1337,7 @@ export function AmsNameHoverCard({
       });
       });
     }
     }
   }, [isVisible, amsLabels, ams.id]);
   }, [isVisible, amsLabels, ams.id]);
-  
+
   const handleMouseEnter = () => {
   const handleMouseEnter = () => {
     if (timeoutRef.current) clearTimeout(timeoutRef.current);
     if (timeoutRef.current) clearTimeout(timeoutRef.current);
     timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
     timeoutRef.current = setTimeout(() => setIsVisible(true), 80);
@@ -5368,6 +5368,7 @@ export function PrintersPage() {
   const { data: spoolAssignments } = useQuery({
   const { data: spoolAssignments } = useQuery({
     queryKey: ['spool-assignments'],
     queryKey: ['spool-assignments'],
     queryFn: () => api.getAssignments(),
     queryFn: () => api.getAssignments(),
+    enabled: hasPermission('inventory:view_assignments'),
     staleTime: 30 * 1000,
     staleTime: 30 * 1000,
   });
   });
 
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-BpZJje_K.js


+ 1 - 1
static/index.html

@@ -23,7 +23,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-DCHZw0ab.js"></script>
+    <script type="module" crossorigin src="/assets/index-BpZJje_K.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DfcIVNpM.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DfcIVNpM.css">
   </head>
   </head>
   <body>
   <body>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů