diff --git a/docs/PLUGIN-LAB-PRINTEDPARTS.md b/docs/PLUGIN-LAB-PRINTEDPARTS.md
index d6e868b..2922d6b 100644
--- a/docs/PLUGIN-LAB-PRINTEDPARTS.md
+++ b/docs/PLUGIN-LAB-PRINTEDPARTS.md
@@ -371,6 +371,20 @@ Two more field requests, and the plugin's FIRST incremental migration:
ACTIVE member of each selected role (role.users backref), deduped with
the user picks and free-text; settings page gains a role picker.
+## Stage 16a (extension) - view permission on the catalog
+
+The catalog started with open reads (the product's jwt-optional list
+convention). Field decision: browsing and managing the parts catalog is
+staff-only, so the reads (list, detail, file listings) move behind
+`@jwt_required()` + `require_permission('printedparts.view')`, the
+`/printedparts` routes and the label print page gain `requiresAuth`, and
+the view permission becomes meaningful in the role grid.
+Deliberately still open: the kiosk endpoints (decision record), the image
+serve and file download (fetched by `
` tags and anchor clicks, which
+cannot carry a JWT header), and the reports (product-wide jwt-optional
+convention). Grant `printedparts.view` to the roles that should see the
+catalog - admins bypass as always.
+
## Stage 16 (extension) - kiosk touch fixes from first hands-on use
First real touchscreen session found two problems worth their own stage:
diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js
index fadf850..2405699 100644
--- a/frontend/src/router/index.js
+++ b/frontend/src/router/index.js
@@ -118,10 +118,12 @@ const routes = [
meta: { plugin: 'usb' }
},
{
+ // Unlike the other print pages this one requires login: it lists the
+ // whole catalog, which is printedparts.view-gated at the API.
path: '/print/printedparts-labels',
name: 'print-printedparts-labels',
component: () => import('../views/print/PrintedPartsLabels.vue'),
- meta: { plugin: 'printedparts' }
+ meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: '/',
diff --git a/frontend/src/router/routes/printedparts.js b/frontend/src/router/routes/printedparts.js
index f769ad3..8b19b25 100644
--- a/frontend/src/router/routes/printedparts.js
+++ b/frontend/src/router/routes/printedparts.js
@@ -12,7 +12,7 @@ export default [
path: 'printedparts',
name: 'printedparts',
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
- meta: { plugin: 'printedparts' }
+ meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/new',
@@ -24,7 +24,7 @@ export default [
path: 'printedparts/:id',
name: 'printedparts-detail',
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
- meta: { plugin: 'printedparts' }
+ meta: { requiresAuth: true, plugin: 'printedparts' }
},
{
path: 'printedparts/:id/edit',
diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py
index 4c07931..18c3072 100644
--- a/plugins/printedparts/api/routes.py
+++ b/plugins/printedparts/api/routes.py
@@ -1,8 +1,11 @@
"""Printedparts plugin API routes.
-Reads are open (jwt optional) like every list surface; mutations arrive in
-later stages with permission gates. The kiosk endpoints (unauthenticated by
-explicit decision - see the proposal) also land later.
+Access model: browsing the catalog (items, detail, file listings) requires
+the printedparts.view permission; every mutation carries its own permission.
+Deliberately open: the kiosk endpoints (decision record in the proposal),
+the image serve and file download (fetched by
tags and anchor
+downloads, which cannot carry a JWT header), and the reports (jwt-optional
+like every other report in the product).
"""
from flask import Blueprint, request
@@ -26,7 +29,8 @@ printedparts_bp = Blueprint('printedparts', __name__)
@printedparts_bp.route('/items', methods=['GET'])
-@jwt_required(optional=True)
+@jwt_required()
+@require_permission('printedparts.view')
def list_items():
"""List printed items, paginated; search + low-stock filter."""
page, per_page = get_pagination_params(request)
@@ -51,7 +55,8 @@ def list_items():
@printedparts_bp.route('/items/', methods=['GET'])
-@jwt_required(optional=True)
+@jwt_required()
+@require_permission('printedparts.view')
def get_item(item_id: int):
"""Get one printed item with its recent transactions."""
item = db.session.get(PrintedItem, item_id)
@@ -578,7 +583,8 @@ def _uploader_name():
@printedparts_bp.route('/items//files', methods=['GET'])
-@jwt_required(optional=True)
+@jwt_required()
+@require_permission('printedparts.view')
def list_item_files(item_id: int):
"""Revision history, newest first."""
files = (PrintedItemFile.query.filter_by(printeditemid=item_id)
diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py
index e0c06ac..48f3aa3 100644
--- a/tests/test_plugins/test_printedparts_ledger.py
+++ b/tests/test_plugins/test_printedparts_ledger.py
@@ -121,6 +121,15 @@ def test_anonymous_cannot_mutate(client, item):
json={'quantity': 1, 'badge': '1'}).status_code == 401
+def test_catalog_reads_require_view_permission(client, member_headers, item):
+ """Browsing the catalog is printedparts.view-gated; the kiosk stays open."""
+ assert client.get('/api/printedparts/items').status_code == 401
+ assert client.get(f'/api/printedparts/items/{item}').status_code == 401
+ assert client.get('/api/printedparts/items',
+ headers=member_headers).status_code == 403
+ assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200
+
+
def test_member_without_permission_gets_403(client, member_headers, item):
"""Authentication alone is not authorization: a role-less user is denied."""
assert client.post('/api/printedparts/items', json={'itemname': 'X'},
@@ -248,12 +257,14 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
assert client.delete(f'/api/printedparts/items/{item}',
headers=auth_headers).status_code == 200
- listed = client.get('/api/printedparts/items').get_json()['data']
+ listed = client.get('/api/printedparts/items',
+ headers=auth_headers).get_json()['data']
assert all(row['printeditemid'] != item for row in listed)
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
assert kiosk.status_code == 404
- including = client.get('/api/printedparts/items?active=false')
+ including = client.get('/api/printedparts/items?active=false',
+ headers=auth_headers)
assert any(row['printeditemid'] == item
for row in including.get_json()['data'])
@@ -286,7 +297,8 @@ def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path
content_type='multipart/form-data')
assert bad.status_code == 400
- listing = client.get(f'/api/printedparts/items/{item}/files').get_json()['data']
+ listing = client.get(f'/api/printedparts/items/{item}/files',
+ headers=auth_headers).get_json()['data']
assert [f['revision'] for f in listing] == [2, 1]
fileid = listing[1]['fileid']