diff --git a/backend/src/routes/adminPhotoDimensions.js b/backend/src/routes/adminPhotoDimensions.js index bc2b4485..a9d2862d 100644 --- a/backend/src/routes/adminPhotoDimensions.js +++ b/backend/src/routes/adminPhotoDimensions.js @@ -64,7 +64,14 @@ function startLeaseKeeper(jobName, token) { } // Repair photo dimensions (background job) -router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), async (req, res) => { +// +// system.manage, not photos.edit: the query below is unscoped, so this walks +// every event in the install, reads every original off S3 or the NAS mount, and +// rewrites their metadata. photos.edit is held by the team_photographer preset +// (175_granular_permissions_and_presets.js:106), which exists for a contributing +// second shooter — someone who should be able to edit the photos they work on, +// not start a whole-library scan or touch another owner's events. +router.post('/repair-dimensions', adminAuth, requirePermission('system.manage'), async (req, res) => { try { // Claimed before the candidate query, not after: that query is an await, // and two requests arriving inside it would both read "not running" and @@ -211,7 +218,13 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a }); // Get dimension repair status -router.get('/repair-dimensions/status', adminAuth, requirePermission('photos.view'), async (req, res) => { +// +// The same permission as the POST, not the read-only system.view. The two are +// independent grants, and StatusTab has no permission gate of its own — a +// successful status payload is what renders the card and its enabled button +// (StatusTab.tsx:558). system.view alone would therefore show a live Repair +// button whose every click 403s with no error surfaced. +router.get('/repair-dimensions/status', adminAuth, requirePermission('system.manage'), async (req, res) => { try { const totalPhotos = await db('photos') .where(function () { diff --git a/frontend/src/features/settings/tabs/StatusTab.tsx b/frontend/src/features/settings/tabs/StatusTab.tsx index 6a6395af..59cc5125 100644 --- a/frontend/src/features/settings/tabs/StatusTab.tsx +++ b/frontend/src/features/settings/tabs/StatusTab.tsx @@ -62,13 +62,25 @@ export const StatusTab: React.FC = ({ const { storageInfo, systemStatus } = useStatusTab(isActive); const queryClient = useQueryClient(); + // Gated on the same permission the endpoint requires, so a role without it + // never starts the poll. Without this the card would poll a 403 every ten + // seconds for anyone who can open the Status tab but cannot run the repair, + // filling the logs with denials for a panel they were never shown. + // + // Named for the card it gates rather than for the permission, because #1179 + // adds a second system.manage-gated card to this same component. Two flags + // with one name merge without a conflict and then fail to compile + // (TS2451) — and since each PR is green on its own, nothing catches it until + // main's build breaks. Once both have landed these can collapse into one. + const canRepairDimensions = usePermission('system.manage'); + const { data: dimensionStatus } = useQuery({ queryKey: ['photo-dimension-status'], queryFn: async () => { const res = await api.get('/admin/photos/repair-dimensions/status'); return res.data; }, - enabled: isActive, + enabled: isActive && canRepairDimensions, refetchInterval: 10000, }); @@ -594,7 +606,11 @@ export const StatusTab: React.FC = ({ )} {/* Photo Dimensions */} - {dimensionStatus && ( + {/* canRepairDimensions as well as the payload: TanStack keeps the cached + status after `enabled` flips false, so without it a lower-privileged + admin logging in behind a system.manage user inside the cache lifetime + would still be shown the card and a button whose POST 403s. */} + {dimensionStatus && canRepairDimensions && (