fix(admin): gate the dimension repair as system maintenance (#1182)

* fix(admin): gate the dimension repair as system maintenance (#1181)

The endpoint's candidate query is unscoped, so it walks every event in the
install, reads every original off S3 or the NAS mount, and rewrites their
metadata. It required only photos.edit, which the built-in team_photographer
preset holds (175_granular_permissions_and_presets.js:106) — a role that
exists for a contributing second shooter, not for someone who should be able
to start a whole-library scan or touch another owner's events.

Now system.manage, whose own description is "run system maintenance actions",
with the status endpoint on system.view to match. Nobody who should have it
loses it: super_admin is granted every permission, solo_photographer is
'ALL', and migration 175 already projects every settings.edit holder forward
onto system.manage on upgrade.

The capture-date sweep next to it was gated this way in #1179; this brings its
older twin in line.

* fix(admin): gate the dimension status card on the permission the button needs (#1181)

Same mismatch as the capture-date card: system.view and system.manage are
independent grants and StatusTab renders its card and enabled button purely on
a successful status payload (StatusTab.tsx:558), so a system.view-only role got
a live Repair button whose every click 403s.

* fix(admin): stop the dimension status card polling a 403 (#1181)

With the endpoint correctly requiring system.manage, anyone who can open the
Status tab but lacks it would have had a 403 and a logged denial every ten
seconds for a panel they were never shown. The query is now gated on the same
permission the endpoint requires, so it never starts.

* fix(admin): gate the dimension card's render on the permission too (#1181)

TanStack keeps the cached status after `enabled` flips false, so checking only
the payload would still show the card — and an enabled Repair button whose POST
403s — to a lower-privileged admin logging in behind a system.manage user
inside the cache lifetime.

* fix(admin): name the dimension-card permission flag for the card it gates (#1181)

#1179 adds a second system.manage-gated card to this same component with the
same flag name. Two identical declarations merge WITHOUT a conflict and then
fail to compile — TS2451, cannot redeclare block-scoped variable — and since
each PR is green on its own, nothing catches it until main's build breaks.

Verified by trial-merging both into main: no conflict, two declarations, tsc
fails on both lines. Naming this one for the card it gates removes the trap;
once both have landed the two flags can collapse into one.

---------

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-26 08:54:04 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 51d20c5920
commit 3991dc3ccb
2 changed files with 33 additions and 4 deletions
+15 -2
View File
@@ -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 () {
@@ -62,13 +62,25 @@ export const StatusTab: React.FC<StatusTabProps> = ({
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<StatusTabProps> = ({
)}
{/* 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 && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Ruler className="w-5 h-5" />