feat(labels): harden Stack Labels (gate parity, abort, dry-run, cap) (#1232)

* feat(labels): harden stack-labels surface (gate parity, abort, dry-run policy, cap)

- Cap labelIds array at MAX_LABELS_PER_NODE on PUT /api/stacks/:name/labels.
- Hide sidebar Labels submenu and Settings mutation buttons for roles
  without stack:edit, matching the backend requirePermission gate.
- Break the per-label bulk-action loop on req.aborted so cancelled requests
  release the per-node lock once the in-flight op completes.
- Reset saving state on success in LabelInlineCreateForm so the form stays
  interactive when reused outside the kebab/context menus.
- Invoke enforcePolicyPreDeploy inside the dry-run deploy branch and report
  blocked stacks honestly; previously dry-run skipped the gate and would
  predict success for stacks the real deploy would block.

* fix(labels): split sidebar gate so inline create matches unscoped backend guard

Codex review of #1232 surfaced a parity miss: POST /api/labels is guarded
by unscoped requirePermission('stack:edit'), but the sidebar inline "New
label" entry was gated by canEditLabels (per-stack scoped). An Admiral
user with only scoped grants on a stack could toggle existing labels but
the inline create request would 403.

Splits the sidebar gate:
- canEditLabels (scoped) keeps gating the Labels submenu trigger and toggle
  items, matching PUT /api/stacks/:name/labels.
- canCreateLabels (unscoped) now gates the inline "New label" entry,
  matching POST /api/labels.

Also restores the swallow-catch in LabelInlineCreateForm. The earlier
catch-to-finally change in #1232 let the rethrow from createAndAssignLabel
surface as an unhandled event-handler rejection in the browser console.
Parents already toast on failure; swallowing in the form is the intended
behavior with the finally reset still in place.
This commit is contained in:
Anso
2026-05-25 23:48:12 -04:00
committed by GitHub
parent 42e8d3a78c
commit 2a29fed117
11 changed files with 219 additions and 47 deletions
+16 -14
View File
@@ -19,7 +19,7 @@ import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sid
export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] {
const {
stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled, openScheduleTask,
@@ -48,19 +48,21 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
groups.push({ id: 'inspect', items: inspect });
const organize: MenuItem[] = [];
organize.push({
id: 'labels',
label: 'Labels',
icon: Tag,
shortcut: 'L ',
onSelect: () => {},
subItems: labels.map(l => ({
id: `label:${l.id}`,
label: l.name,
if (canEditLabels) {
organize.push({
id: 'labels',
label: 'Labels',
icon: Tag,
onSelect: () => toggleLabel(l.id),
})),
});
shortcut: 'L ',
onSelect: () => {},
subItems: labels.map(l => ({
id: `label:${l.id}`,
label: l.name,
icon: Tag,
onSelect: () => toggleLabel(l.id),
})),
});
}
organize.push(
isPinned
? { id: 'pin', label: 'Unpin', icon: PinOff, shortcut: 'P', onSelect: unpin }
@@ -85,7 +87,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[]
return groups;
}, [
stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate,
autoUpdateEnabled, setAutoUpdateEnabled,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,