mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
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:
@@ -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[];
|
||||
|
||||
@@ -12,6 +12,8 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
||||
isPaid: true,
|
||||
isAdmiral: false,
|
||||
canDelete: true,
|
||||
canEditLabels: true,
|
||||
canCreateLabels: true,
|
||||
isPinned: false,
|
||||
labels: [],
|
||||
assignedLabelIds: [],
|
||||
@@ -106,6 +108,16 @@ describe('useStackMenuItems', () => {
|
||||
expect(labelsItem?.subItems?.[0]).toMatchObject({ id: 'label:1', label: 'prod' });
|
||||
});
|
||||
|
||||
it('hides the Labels submenu when !canEditLabels (viewer role)', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
|
||||
canEditLabels: false,
|
||||
labels: [{ id: 1, node_id: 0, name: 'prod', color: 'teal' }],
|
||||
})));
|
||||
const organize = result.current.find(g => g.id === 'organize')!;
|
||||
expect(organize.items.find(i => i.id === 'labels')).toBeUndefined();
|
||||
expect(organize.items.find(i => i.id === 'pin')).toBeDefined();
|
||||
});
|
||||
|
||||
it('auto-update toggle calls setAutoUpdateEnabled with toggled value', () => {
|
||||
const setAutoUpdateEnabled = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user