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
@@ -43,6 +43,11 @@ export function useSidebarContextMenu({
isPaid,
isAdmiral,
canDelete: can('stack:delete', 'stack', sName),
canEditLabels: can('stack:edit', 'stack', sName),
// POST /api/labels (the inline "New label" entry) is guarded by the
// unscoped requirePermission('stack:edit'); a user with only per-stack
// scoped edit can toggle existing labels but cannot create new ones.
canCreateLabels: can('stack:edit'),
isPinned: stackListState.isPinned(file),
labels: stackListState.labels,
assignedLabelIds: (stackListState.stackLabelMap[file] ?? []).map(l => l.id),
@@ -6,6 +6,7 @@ import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/comp
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
import { useAuth } from '@/context/AuthContext';
import { CapabilityGate } from '../CapabilityGate';
import { LabelDot } from '../LabelPill';
import { LABEL_COLORS, MAX_LABELS_PER_NODE, type Label, type LabelColor } from '../label-types';
@@ -18,6 +19,11 @@ interface LabelsSectionProps {
}
export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
const { can } = useAuth();
// Mirrors the backend `requirePermission('stack:edit')` guard on
// POST/PUT/DELETE /api/labels, keeping a viewer/deployer/auditor from
// clicking through to a guaranteed 403.
const canMutate = can('stack:edit');
const [labels, setLabels] = useState<Label[]>([]);
const [loading, setLoading] = useState(true);
const [assignmentCounts, setAssignmentCounts] = useState<Record<number, number>>({});
@@ -128,12 +134,14 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
return (
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={openCreate} disabled={labels.length >= MAX_LABELS_PER_NODE}>
<Plus className="w-4 h-4" strokeWidth={1.5} />
{labels.length >= MAX_LABELS_PER_NODE ? 'Limit reached' : 'New label'}
</SettingsPrimaryButton>
</div>
{canMutate && (
<div className="flex justify-end">
<SettingsPrimaryButton size="sm" onClick={openCreate} disabled={labels.length >= MAX_LABELS_PER_NODE}>
<Plus className="w-4 h-4" strokeWidth={1.5} />
{labels.length >= MAX_LABELS_PER_NODE ? 'Limit reached' : 'New label'}
</SettingsPrimaryButton>
</div>
)}
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
{loading ? (
@@ -153,22 +161,26 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
<span className="text-xs text-muted-foreground tabular-nums">
{assignmentCounts[label.id] || 0} stack{(assignmentCounts[label.id] || 0) !== 1 ? 's' : ''}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => openEdit(label)}
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setDeleteTarget(label)}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
{canMutate && (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => openEdit(label)}
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setDeleteTarget(label)}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
</>
)}
</div>
))}
</div>
@@ -20,6 +20,12 @@ export function LabelInlineCreateForm({ onSubmit, onCancel }: LabelInlineCreateF
try {
await onSubmit(trimmed, color);
} catch {
// Parents (createAndAssignLabel) toast and rethrow to signal failure;
// swallow here so the rejection does not surface as an unhandled
// event-handler rejection in the browser console.
} finally {
// Reset even on success so the form stays interactive if the parent
// keeps it mounted (e.g. when reused outside the kebab/context menu).
setSaving(false);
}
};
@@ -63,7 +63,7 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
);
})}
<ContextMenuSeparator />
{ctx.labels.length < MAX_LABELS_PER_NODE && (
{ctx.canCreateLabels && ctx.labels.length < MAX_LABELS_PER_NODE && (
<ContextMenuItem onSelect={e => { e.preventDefault(); setCreating(true); }}>
<Plus className="w-3.5 h-3.5 mr-2 text-muted-foreground" strokeWidth={1.5} />
<span className="text-xs">New label</span>
@@ -63,7 +63,7 @@ function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
);
})}
<DropdownMenuSeparator />
{ctx.labels.length < MAX_LABELS_PER_NODE && (
{ctx.canCreateLabels && ctx.labels.length < MAX_LABELS_PER_NODE && (
<DropdownMenuItem onSelect={e => { e.preventDefault(); setCreating(true); }}>
<Plus className="w-3.5 h-3.5 mr-2 text-muted-foreground" strokeWidth={1.5} />
<span className="text-xs">New label</span>
@@ -28,6 +28,8 @@ export interface StackMenuCtx {
isPaid: boolean;
isAdmiral: boolean;
canDelete: boolean;
canEditLabels: boolean;
canCreateLabels: boolean;
isPinned: boolean;
labels: Label[];
assignedLabelIds: number[];