fix(rbac): enforce operational permission parity (#1736)

This commit is contained in:
Anso
2026-07-29 22:02:53 -04:00
committed by GitHub
parent a65bf4d46a
commit c704cb54d2
68 changed files with 708 additions and 232 deletions
+1 -1
View File
@@ -431,7 +431,7 @@ export function AppStoreView({ onDeploySuccess, headerActions }: AppStoreViewPro
label: isDeploying ? 'Deploying…' : `Deploy ${selectedTemplate.title}`,
icon: isDeploying ? Loader2 : Rocket,
onClick: handleDeploy,
disabled: isDeploying || !stackName.trim() || !can('stack:create'),
disabled: isDeploying || !stackName.trim() || !can('stack:create') || !can('stack:deploy'),
} : undefined}
footerContext={isDeploying ? 'This may take a few minutes for large images.' : undefined}
size="md"
@@ -443,7 +443,6 @@ export function EditorView(props: EditorViewProps) {
safeContainers={safeContainers}
isRunning={isRunning}
can={can}
isAdmin={isAdmin}
trivy={trivy}
backupInfo={backupInfo}
loadingAction={loadingAction}
@@ -147,7 +147,6 @@ export function MobileStackDetail(props: EditorViewProps) {
safeContainers={safeContainers}
isRunning={isRunning}
can={can}
isAdmin={isAdmin}
trivy={trivy}
backupInfo={backupInfo}
loadingAction={loadingAction}
@@ -222,7 +222,7 @@ export function ShellOverlays({
<VulnerabilityScanSheet
scanId={stackMisconfigScanId}
onClose={() => setStackMisconfigScanId(null)}
canManageSuppressions={isAdmin}
canManageSuppressions={can('stack:edit')}
/>
{/* Compose diff preview */}
@@ -39,7 +39,6 @@ function renderHeader(over: Partial<ComponentProps<typeof StackIdentityHeader>>
safeContainers={CONTAINERS}
isRunning
can={() => true}
isAdmin
trivy={{ available: false }}
backupInfo={{ exists: false, timestamp: null }}
loadingAction={null}
@@ -114,4 +113,15 @@ describe('StackIdentityHeader', () => {
expect(onOpenMonitor).toHaveBeenCalledTimes(1);
});
it('shows stack config scanning to a deployer without requiring Admin', async () => {
const user = userEvent.setup();
renderHeader({
trivy: { available: true },
can: (action) => action === 'stack:deploy',
});
await user.click(screen.getByRole('button', { name: 'More actions' }));
expect(screen.getByRole('menuitem', { name: 'Scan config' })).toBeInTheDocument();
});
});
@@ -127,7 +127,6 @@ export interface StackIdentityHeaderProps {
safeContainers: ContainerInfo[];
isRunning: boolean;
can: ReturnType<typeof useAuth>['can'];
isAdmin: boolean;
trivy: { available: boolean };
backupInfo: { exists: boolean; timestamp: number | null };
loadingAction: StackAction | null;
@@ -156,7 +155,6 @@ export function StackIdentityHeader({
safeContainers,
isRunning,
can,
isAdmin,
trivy,
backupInfo,
loadingAction,
@@ -206,7 +204,7 @@ export function StackIdentityHeader({
const canDeploy = can('stack:deploy', 'stack', stackName, activeNode?.id);
const canDelete = can('stack:delete', 'stack', stackName, activeNode?.id);
const canRollback = canDeploy && backupInfo.exists;
const canScan = trivy.available && isAdmin;
const canScan = trivy.available && canDeploy;
const canMute = stackMuteActions?.canMute ?? false;
const hasOverflowExtras = canRollback || canScan;
const hasOverflow = hasOverflowExtras || canDelete || canMute || onOpenMonitor;
+14 -6
View File
@@ -63,7 +63,9 @@ export function FleetView({
onFleetActiveTabChange,
}: FleetViewProps) {
const { isPaid, licenseStatus } = useLicense();
const { isAdmin } = useAuth();
const { isAdmin, can } = useAuth();
const canManageFleet = can('node:manage');
const canExportDossier = can('node:read') && can('stack:read');
const { hasCapability } = useNodes();
const { experimental, experimentalReady } = useExperimental();
const containerLabelsEnabled = hasCapability('container-label-inventory');
@@ -240,7 +242,7 @@ export function FleetView({
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
</TooltipProvider>
{isAdmin && (
{canExportDossier && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -289,8 +291,8 @@ export function FleetView({
onRetryUpdate={updateStatus.retryNodeUpdate}
onDismissUpdate={updateStatus.dismissNodeUpdate}
onCordonChange={() => { void overview.fetchOverview(true); }}
onEditNode={isAdmin ? openEdit : undefined}
onDeleteNode={isAdmin ? openDelete : undefined}
onEditNode={openEdit}
onDeleteNode={openDelete}
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
onCheckUpdates={updateStatus.checkUpdates}
@@ -324,12 +326,18 @@ export function FleetView({
{canDiscoverRouting && (
<TabsContent value="routing">
<PaidGate>
<RoutingTab canManage={isAdmin} />
<RoutingTab
canManageNode={(nodeId) => can('node:manage', 'node', String(nodeId))}
canManageMembership={isAdmin}
/>
</PaidGate>
</TabsContent>
)}
<TabsContent value="federation">
<FederationTab canManage={isAdmin} />
<FederationTab
canManage={canManageFleet}
canManageNode={(nodeId) => can('node:manage', 'node', String(nodeId))}
/>
</TabsContent>
<TabsContent value="actions">
{/* Fleet Actions runs against the whole fleet, so it takes the
@@ -79,10 +79,11 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
const { nodes: registryNodes } = useNodes();
const registryNode = registryNodes.find(n => n.id === node.id);
const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1;
const canEdit = Boolean(isAdmin && onEdit && registryNode);
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
const canManageNode = can('node:manage', 'node', String(node.id));
const canEdit = Boolean(canManageNode && onEdit && registryNode);
const canDelete = Boolean(canManageNode && onDelete && registryNode && !registryNode.is_default && !isLastLocal);
// Cordon is permission-gated only (node:manage), matching the backend route guard.
const canCordon = can('node:manage', 'node', String(node.id));
const canCordon = canManageNode;
const nodeMuteActions = useNodeMuteActions(
node.id,
node.name,
@@ -62,6 +62,20 @@ describe('NodeCard', () => {
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
});
it('shows edit and delete controls to a scoped node manager who is not an admin', async () => {
const node = onlineNode();
const registryNode = { id: 2, name: 'Edge', type: 'remote', is_default: false };
const onEdit = vi.fn();
const onDelete = vi.fn();
useNodesMock.mockReturnValue({ nodes: [registryNode, { id: 1, type: 'local' }], hasCapability: vi.fn(() => false) });
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn((action: string) => action === 'node:manage') });
render(<NodeCard {...baseProps(node)} onEdit={onEdit} onDelete={onDelete} />);
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
expect(await screen.findByText('Edit node')).toBeInTheDocument();
expect(screen.getByText('Delete node')).toBeInTheDocument();
});
it('hides the cordon control from a user lacking node:manage', () => {
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
render(<NodeCard {...baseProps(onlineNode())} />);
+1 -2
View File
@@ -41,7 +41,6 @@ export interface SenchoNavigateDetail {
export function NodeManager() {
const { isPaid } = useLicense();
const { isAdmin, can } = useAuth();
const canEditLabels = isAdmin;
// Mirror the backend node:manage guard. This top-level flag checks the global
// role only (admin or global node-admin); the per-row Test/Edit/Delete buttons
// below additionally honor scoped per-node grants via can('node:manage', 'node', id).
@@ -374,7 +373,7 @@ export function NodeManager() {
</TableCell>
<TableCell>{getStatusBadge(node.status)}</TableCell>
<TableCell>
<NodeLabelPicker nodeId={node.id} canEdit={canEditLabels} />
<NodeLabelPicker nodeId={node.id} canEdit={can('node:manage', 'node', String(node.id))} />
</TableCell>
<TableCell>
{(() => {
+10 -7
View File
@@ -367,7 +367,10 @@ interface ResourcesViewProps {
export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) {
const isMobile = useIsMobile();
const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged'>('images');
const { isAdmin } = useAuth();
const { isAdmin, can } = useAuth();
const canReadResources = can('stack:read');
const canDeployResources = can('stack:deploy');
const canEditSecurityPolicy = can('stack:edit');
const { activeNode } = useNodes();
const [usage, setUsage] = useState<UsageData | null>(null);
const [images, setImages] = useState<DockerImage[]>([]);
@@ -1076,7 +1079,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
<TooltipContent>Inspect image</TooltipContent>
</Tooltip>
</TooltipProvider>
{trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
{trivy.available && canDeployResources && img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>' && (
<DropdownMenu>
<TooltipProvider>
<Tooltip>
@@ -1214,7 +1217,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{isAdmin && (
{canReadResources && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -1479,11 +1482,11 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {}
<VulnerabilityScanSheet
scanId={inspectScanId}
onClose={() => setInspectScanId(null)}
onRescan={isAdmin ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
canGenerateSbom={isAdmin}
canExportSarif={isAdmin}
onRescan={canDeployResources ? (imageRef) => { setInspectScanId(null); handleScanImage(imageRef, { force: true }); } : undefined}
canGenerateSbom={canReadResources}
canExportSarif={canReadResources}
canCompare
canManageSuppressions={isAdmin}
canManageSuppressions={canEditSecurityPolicy}
/>
</>
);
+10 -7
View File
@@ -63,7 +63,7 @@ const MOBILE_MASTHEAD_TONE: Record<MastheadTone, { dot: Tone; word: StateWordCla
};
export function SecurityView({ activeTab, onTabChange, headerActions }: SecurityViewProps) {
const { isAdmin } = useAuth();
const { can } = useAuth();
const { activeNode } = useNodes();
const isMobile = useIsMobile();
const isRemote = activeNode?.type === 'remote';
@@ -104,7 +104,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
// Scanner readiness gates the Images Actions column; an admin on a node whose
// scanner is available can trigger scans inline.
const canScan = isAdmin && !!overview?.scanner.available;
const canScanImages = can('stack:deploy') && !!overview?.scanner.available;
const canScanNode = can('node:manage') && !!overview?.scanner.available;
const canReadSecurityExports = can('stack:read');
const canEditSecurityPolicy = can('stack:edit');
const { scanningRef, scanImage } = useImageScan({
onComplete: (scanId) => onInspect(scanId, 'vulns'),
onSummaries: setSummaries,
@@ -264,7 +267,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
exploitTruncated={exploitTruncated}
onNavigate={handleNavigate}
onInspect={onInspect}
canScan={canScan}
canScan={canScanNode}
onScanComplete={() => setReloadToken((t) => t + 1)}
/>
</TabsContent>
@@ -276,7 +279,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
loading={summariesLoading}
error={summariesError}
onInspect={onInspect}
canScan={canScan}
canScan={canScanImages}
scanningRef={scanningRef}
onScan={scanImage}
initialFilter={imagesFilter ?? undefined}
@@ -336,10 +339,10 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
scanId={inspectScanId}
initialTab={inspectInitialTab}
onClose={() => setInspectScanId(null)}
canGenerateSbom={isAdmin}
canExportSarif={isAdmin}
canGenerateSbom={canReadSecurityExports}
canExportSarif={canReadSecurityExports}
canCompare
canManageSuppressions={isAdmin}
canManageSuppressions={canEditSecurityPolicy}
/>
);
@@ -11,7 +11,7 @@ vi.mock('@/context/LicenseContext', () => ({
useLicense: () => ({ isPaid: true }),
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
useAuth: () => ({ isAdmin: true, can: () => true }),
}));
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ hasCapability: () => false }),
@@ -28,7 +28,7 @@ vi.mock('@/components/ui/toast-store', () => ({
const licenseState = { isPaid: true };
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => licenseState }));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true, can: () => true }) }));
const nodesState: { activeNode: { id: number } | null } = { activeNode: { id: 1 } };
vi.mock('@/context/NodeContext', () => ({ useNodes: () => nodesState }));
@@ -12,7 +12,9 @@ import { formatTimeAgo } from '@/lib/relativeTime';
interface BlueprintDeploymentTableProps {
deployments: BlueprintDeployment[];
classification: BlueprintClassification;
canEdit: boolean;
canDeploy: (nodeId: number) => boolean;
canWithdraw: (nodeId: number) => boolean;
canRetry: boolean;
busyNodeId: number | null;
onWithdraw: (nodeId: number) => void;
onAcceptStateReview: (nodeId: number) => void;
@@ -51,7 +53,7 @@ function statusDotClass(status: BlueprintDeploymentStatus): string {
}
export function BlueprintDeploymentTable({
deployments, classification, canEdit, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null,
deployments, classification, canDeploy, canWithdraw, canRetry, busyNodeId, onWithdraw, onAcceptStateReview, onRetry, pinnedNodeId = null,
}: BlueprintDeploymentTableProps) {
const { nodes } = useNodes();
const nodesById = new Map(nodes.map(n => [n.id, n]));
@@ -122,7 +124,7 @@ export function BlueprintDeploymentTable({
</TableCell>
<TableCell className="align-top">
<div className="flex items-center justify-end gap-1">
{dep.status === 'pending_state_review' && canEdit && (
{dep.status === 'pending_state_review' && canDeploy(dep.node_id) && (
<Button
size="sm"
variant="default"
@@ -138,7 +140,7 @@ export function BlueprintDeploymentTable({
Resolve manually
</span>
)}
{dep.status === 'failed' && canEdit && (
{dep.status === 'failed' && canRetry && (
<Button
size="sm"
variant="outline"
@@ -148,7 +150,7 @@ export function BlueprintDeploymentTable({
Retry
</Button>
)}
{(dep.status === 'active' || dep.status === 'drifted' || dep.status === 'evict_blocked' || dep.status === 'failed') && canEdit && (
{(dep.status === 'active' || dep.status === 'drifted' || dep.status === 'evict_blocked' || dep.status === 'failed') && canWithdraw(dep.node_id) && (
<Button
size="sm"
variant="ghost"
@@ -1,10 +1,8 @@
/**
* Render-gate coverage for BlueprintDetail's action bar.
*
* The Apply / Edit / Disable / Delete actions all hit admin-only routes
* (e.g. POST /api/blueprints/:id/apply requires admin). This locks the UI gate:
* an admin (canEdit) sees the action affordances; a non-admin viewer sees none
* of them, so the sheet can never issue a request the API answers with 403.
* Blueprint actions use distinct stack permissions. These tests lock the UI
* gates so each role sees only actions accepted by the API.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
@@ -149,4 +147,24 @@ describe('BlueprintDetail action gating', () => {
// The detail is still viewable: the compose source and deployment table render.
expect(screen.getByTestId('deployment-table')).toBeInTheDocument();
});
it('lets a deployer apply without exposing edit or delete', async () => {
const can = vi.fn((action: string) => action === 'stack:create' || action === 'stack:deploy');
render(
<BlueprintDetail
blueprintId={1}
open
onOpenChange={noop}
onChanged={noop}
canEdit={false}
can={can}
distinctLabels={[]}
/>,
);
expect(await screen.findByText('Show compose source')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /apply now/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^delete$/i })).not.toBeInTheDocument();
});
});
@@ -26,6 +26,9 @@ import { StateReviewDialog } from './StateReviewDialog';
import { RolloutPreviewDialog } from './RolloutPreviewDialog';
import { useNodes } from '@/context/NodeContext';
import { formatTimeAgo } from '@/lib/relativeTime';
import type { PermissionAction } from '@/context/AuthContext';
type PermissionResolver = (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
interface BlueprintDetailProps {
blueprintId: number;
@@ -33,10 +36,11 @@ interface BlueprintDetailProps {
onOpenChange: (open: boolean) => void;
onChanged: () => void;
canEdit: boolean;
can?: PermissionResolver;
distinctLabels: string[];
}
export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, canEdit, distinctLabels }: BlueprintDetailProps) {
export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, canEdit, can, distinctLabels }: BlueprintDetailProps) {
const [summary, setSummary] = useState<BlueprintSummary | null>(null);
const [loading, setLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
@@ -78,6 +82,12 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
if (!open) return null;
const blueprint = summary?.blueprint;
const canApply = !!blueprint && (can ? can('stack:create') && can('stack:deploy') : canEdit);
const canDeleteBlueprint = !!blueprint && (can ? can('stack:delete') : canEdit);
const canDeployOnNode = (nodeId: number) => !!blueprint
&& (can ? can('stack:deploy', 'stack', blueprint.name, nodeId) : canEdit);
const canWithdrawFromNode = (nodeId: number) => !!blueprint
&& (can ? can('stack:delete', 'stack', blueprint.name, nodeId) : canEdit);
async function handleRolloutApplied() {
await refresh();
@@ -236,14 +246,14 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
crumb={['Blueprints', blueprint?.name ?? '…']}
name={blueprint?.name ?? <Skeleton className="h-7 w-40 inline-block" />}
meta={meta}
primaryAction={blueprint && canEdit ? {
primaryAction={canApply ? {
label: 'Apply now',
icon: Play,
onClick: () => setPreviewOpen(true),
disabled: submitting || !blueprint.enabled || editMode,
} : undefined}
secondaryActions={secondaryActions}
destructiveAction={blueprint && canEdit ? {
destructiveAction={canDeleteBlueprint ? {
label: 'Delete',
icon: Trash2,
onClick: () => setDeleteOpen(true),
@@ -294,7 +304,9 @@ export function BlueprintDetail({ blueprintId, open, onOpenChange, onChanged, ca
<BlueprintDeploymentTable
deployments={summary.deployments}
classification={blueprint.classification}
canEdit={canEdit}
canDeploy={canDeployOnNode}
canWithdraw={canWithdrawFromNode}
canRetry={canApply}
busyNodeId={busyNodeId}
onWithdraw={openWithdraw}
onAcceptStateReview={openAcceptStateReview}
@@ -19,8 +19,9 @@ import { BlueprintEditor } from './BlueprintEditor';
import { useAuth } from '@/context/AuthContext';
export function DeploymentsTab() {
const { isAdmin } = useAuth();
const canEdit = isAdmin;
const { can } = useAuth();
const canCreate = can('stack:create');
const canEdit = can('stack:edit');
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
const [distinctLabels, setDistinctLabels] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
@@ -100,7 +101,7 @@ export function DeploymentsTab() {
<FleetTabHeading
title="Blueprints"
subtitle="Declare compose templates once and keep matching nodes in sync."
action={canEdit ? (
action={canCreate ? (
<Button size="sm" className="gap-1.5" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4" strokeWidth={1.5} />
New Blueprint
@@ -108,7 +109,7 @@ export function DeploymentsTab() {
) : undefined}
/>
<FleetEmptyState>
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} canCreate={canEdit} />
<BlueprintEmptyState onCreate={() => setCreateOpen(true)} canCreate={canCreate} />
</FleetEmptyState>
</>
) : (
@@ -116,7 +117,7 @@ export function DeploymentsTab() {
blueprints={blueprints}
onSelect={setSelectedId}
onCreate={() => setCreateOpen(true)}
canCreate={canEdit}
canCreate={canCreate}
/>
)}
@@ -127,6 +128,7 @@ export function DeploymentsTab() {
onOpenChange={(o) => { if (!o) setSelectedId(null); }}
onChanged={refresh}
canEdit={canEdit}
can={can}
distinctLabels={distinctLabels}
/>
)}
@@ -1,9 +1,9 @@
/**
* Render-gate coverage for FederationTab's pin control.
*
* Pinning a blueprint to a node is admin-only on the backend
* (PUT /api/blueprints/:id/pin requires admin). This test locks the matching UI
* gate: an admin sees an editable Select, a non-admin sees the placement
* Pinning a blueprint to a node is permission-gated on the backend. This test
* locks the matching UI gate: a manager sees an editable Select, while a user
* without permission sees the placement
* read-only with an explanatory hint. Without this the affordance can drift
* back to rendering an enabled control that the API rejects with 403.
*/
@@ -67,7 +67,7 @@ describe('FederationTab pin gating', () => {
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.queryByText(/Pin changes require an administrator/i)).not.toBeInTheDocument();
expect(screen.queryByText(/do not have permission to change pin placement/i)).not.toBeInTheDocument();
});
it('renders the pin placement read-only for a non-admin', async () => {
@@ -75,9 +75,9 @@ describe('FederationTab pin gating', () => {
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
expect(screen.getByText(/Pin changes require an administrator/i)).toBeInTheDocument();
expect(screen.getByText(/do not have permission to change pin placement/i)).toBeInTheDocument();
expect(screen.getByText('(unpinned)')).toBeInTheDocument();
// The read-only branch must never be able to issue the admin-only pin request.
// The read-only branch must never be able to issue the pin request.
expect(vi.mocked(pinBlueprint)).not.toHaveBeenCalled();
});
@@ -91,4 +91,12 @@ describe('FederationTab pin gating', () => {
// "Effective" column, so getAllByText (not getByText) is required.
expect(screen.getAllByText('node-alpha').length).toBeGreaterThan(0);
});
it('shows pin controls for a scoped node manager', async () => {
render(<FederationTab canManage={false} canManageNode={(nodeId) => nodeId === 1} />);
expect(await screen.findByText('web-blueprint')).toBeInTheDocument();
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.queryByText(/do not have permission to change pin placement/i)).not.toBeInTheDocument();
});
});
@@ -26,12 +26,12 @@ function formatTimestamp(ms: number | null): string {
}
interface FederationTabProps {
/** Whether the current user may change pin placement. Pinning is admin-only on the backend
* (PUT /api/blueprints/:id/pin requires admin); non-admins see the placement read-only. */
/** Whether the current user may change pin placement. */
canManage: boolean;
canManageNode?: (nodeId: number) => boolean;
}
export function FederationTab({ canManage }: FederationTabProps) {
export function FederationTab({ canManage, canManageNode }: FederationTabProps) {
const [nodes, setNodes] = useState<NodeRecord[]>([]);
const [blueprints, setBlueprints] = useState<BlueprintListItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -61,6 +61,7 @@ export function FederationTab({ canManage }: FederationTabProps) {
for (const node of nodes) map.set(node.id, node.name);
return map;
}, [nodes]);
const canManageAnyNode = canManage || nodes.some(node => canManageNode?.(node.id));
const handlePinChange = useCallback(async (blueprintId: number, value: string) => {
const nodeId = value === UNPINNED ? null : Number.parseInt(value, 10);
@@ -149,7 +150,7 @@ export function FederationTab({ canManage }: FederationTabProps) {
<h3 className="text-sm font-medium">Pin policy</h3>
<span className="text-xs text-muted-foreground">
Force a blueprint onto a specific node, overriding its selector.
{!canManage && ' Pin changes require an administrator.'}
{!canManageAnyNode && ' You do not have permission to change pin placement.'}
</span>
</div>
<div className="p-4">
@@ -188,7 +189,7 @@ export function FederationTab({ canManage }: FederationTabProps) {
{describeSelector(bp.selector)}
</td>
<td className="py-2 pr-4 align-top">
{canManage ? (
{canManageAnyNode ? (
<Select
value={bp.pinned_node_id !== null ? String(bp.pinned_node_id) : UNPINNED}
onValueChange={(value) => void handlePinChange(bp.id, value)}
@@ -198,9 +199,13 @@ export function FederationTab({ canManage }: FederationTabProps) {
<SelectValue placeholder="(unpinned)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNPINNED}>(unpinned)</SelectItem>
<SelectItem value={UNPINNED} disabled={!canManage}>(unpinned)</SelectItem>
{nodes.map(node => (
<SelectItem key={node.id} value={String(node.id)}>
<SelectItem
key={node.id}
value={String(node.id)}
disabled={!canManage && !canManageNode?.(node.id)}
>
{node.name}
{node.cordoned ? ' · cordoned' : ''}
</SelectItem>
@@ -2,12 +2,14 @@ import type { FleetNode } from '@/components/FleetView/types';
import { LabelFleetStopCard } from './cards/LabelFleetStopCard';
import { BulkLabelAssignCard } from './cards/BulkLabelAssignCard';
import { FleetPruneCard } from './cards/FleetPruneCard';
import { useAuth } from '@/context/AuthContext';
interface Props {
nodes: FleetNode[];
}
export function FleetActionsTab({ nodes }: Props) {
const { isAdmin } = useAuth();
if (nodes.length === 0) {
return (
<div className="text-sm text-stat-subtitle">Add a node to the fleet to use bulk actions.</div>
@@ -22,7 +24,7 @@ export function FleetActionsTab({ nodes }: Props) {
// Order: Prune top-left, Bulk top-right, Stop on row 2.
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[18px] auto-rows-fr">
<FleetPruneCard nodes={nodes} />
{isAdmin && <FleetPruneCard nodes={nodes} />}
<BulkLabelAssignCard nodes={nodes} />
<LabelFleetStopCard />
</div>
@@ -11,6 +11,7 @@ import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() }));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => true }) }));
const toastError = vi.fn();
const toastSuccess = vi.fn();
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
import type { FleetNode } from '@/components/FleetView/types';
import { type Label, type LabelColor, LABEL_COLORS } from '@/components/label-types';
import { ResultsList, type ResultRow } from '../ResultsList';
import { useAuth } from '@/context/AuthContext';
interface NodeStackResult { stackName: string; success: boolean; error?: string }
interface AssignNodeResult {
@@ -50,6 +51,7 @@ type PreviewNode = { nodeId: number; nodeName: string; willCreate: boolean; stac
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
export function BulkLabelAssignCard({ nodes }: Props) {
const { can } = useAuth();
const [nodeData, setNodeData] = useState<NodeData[]>([]);
const [loading, setLoading] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<LabelTemplate | null>(null);
@@ -144,6 +146,10 @@ export function BulkLabelAssignCard({ nodes }: Props) {
() => Array.from(selected.values()).filter(s => s.size > 0).length,
[selected],
);
const canApplySelection = useMemo(() =>
[...selected].every(([nodeId, stackNames]) =>
[...stackNames].every(stackName => can('stack:edit', 'stack', stackName, nodeId))),
[selected, can]);
function toggleStack(nodeId: number, stackName: string) {
setSelected(prev => {
@@ -180,7 +186,7 @@ export function BulkLabelAssignCard({ nodes }: Props) {
}
async function run() {
if (!selectedTemplate || totalSelected === 0) return;
if (!selectedTemplate || totalSelected === 0 || !canApplySelection) return;
const targets = Array.from(selected.entries())
.filter(([, set]) => set.size > 0)
.map(([nodeId, set]) => ({ nodeId, stackNames: Array.from(set) }));
@@ -282,7 +288,7 @@ export function BulkLabelAssignCard({ nodes }: Props) {
label: 'Apply',
onClick: () => setConfirmOpen(true),
variant: 'primary',
disabled: running || !selectedTemplate || totalSelected === 0,
disabled: running || !selectedTemplate || totalSelected === 0 || !canApplySelection,
}}
footerContext="Reversible · yes · reassign anytime"
>
@@ -17,6 +17,7 @@ vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
fetchForNode: vi.fn(),
}));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => true }) }));
const toastError = vi.fn();
const toastSuccess = vi.fn();
@@ -7,6 +7,7 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { ResultsList, type ResultRow } from '../ResultsList';
import { useAuth } from '@/context/AuthContext';
interface NodeStackResult { stackName: string; success: boolean; error?: string; dryRun?: boolean }
interface FleetStopNodeResult {
@@ -73,6 +74,7 @@ function isMatchPreviewResponse(value: unknown): value is MatchPreviewResponse {
}
export function LabelFleetStopCard() {
const { can } = useAuth();
const [labelName, setLabelName] = useState('');
const [suggestions, setSuggestions] = useState<FleetStopLabelSuggestion[]>([]);
const [suggestUnreachable, setSuggestUnreachable] = useState(0);
@@ -193,6 +195,8 @@ export function LabelFleetStopCard() {
async function run(opts: { dryRun: boolean; targets?: { nodeId: number; stackNames: string[] }[] }) {
const trimmed = labelName.trim();
if (!trimmed) return;
if (opts.targets?.some(target => target.stackNames.some(stackName =>
!can('stack:deploy', 'stack', stackName, target.nodeId)))) return;
const verb = opts.dryRun ? 'Dry-running' : 'Stopping';
const toastId = toast.loading(`${verb} stacks with the stack label "${trimmed}" across the fleet…`);
setRunning(true);
@@ -303,7 +307,11 @@ export function LabelFleetStopCard() {
// The real Stop is enabled only once the blast radius is resolved to at least
// one stack, and never while a run is in flight. Loading/unavailable previews,
// 0-match results, and an invalidated dry-run snapshot all leave it disabled.
const canStopFleet = !running && resolvedTargets !== null && resolvedTargets.some(t => t.stackNames.length > 0);
const canStopFleet = !running
&& resolvedTargets !== null
&& resolvedTargets.some(t => t.stackNames.length > 0)
&& resolvedTargets.every(target => target.stackNames.every(stackName =>
can('stack:deploy', 'stack', stackName, target.nodeId)));
const previewSection = renderPreviewSection(preview, trimmed);
@@ -1,11 +1,10 @@
/**
* Render-gate coverage for MeshOptInSheet's opt-in/out controls.
*
* Opting a stack in or out is admin-only on the backend
* (POST /api/mesh/nodes/:id/stacks/:stack/opt-in|opt-out require admin). This
* test locks the matching UI gate: a manager sees Add/Remove buttons, a
* Opting a stack in or out is Admin-only because it cascades across mesh stacks.
* This test locks the matching UI gate: an Admin sees Add/Remove buttons, while a user without
* non-manager sees the membership read-only with a hint. The read-only branch
* must never issue the admin-only mutation.
* must never issue the mutation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
@@ -51,7 +50,7 @@ describe('MeshOptInSheet canManage gate', () => {
expect(await screen.findByText('web')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Remove from mesh/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Add to mesh/i })).toBeInTheDocument();
expect(screen.queryByText(/Changing mesh membership requires an administrator/i)).not.toBeInTheDocument();
expect(screen.queryByText(/changing mesh membership requires an administrator/i)).not.toBeInTheDocument();
});
it('renders the membership read-only for a non-manager', async () => {
@@ -59,7 +58,7 @@ describe('MeshOptInSheet canManage gate', () => {
expect(await screen.findByText('web')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Remove from mesh/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Add to mesh/i })).not.toBeInTheDocument();
expect(screen.getByText(/Changing mesh membership requires an administrator/i)).toBeInTheDocument();
expect(screen.getByText(/changing mesh membership requires an administrator/i)).toBeInTheDocument();
// The read-only branch must never issue an opt-in/opt-out request.
expect(vi.mocked(apiFetch)).not.toHaveBeenCalledWith(
expect.stringContaining('/opt-'),
@@ -13,7 +13,7 @@ interface Props {
nodeId: number;
nodeName: string;
onChanged: () => void;
/** Opt-in/out is admin-only on the backend; non-admins see the list read-only. */
/** Whether the user may start the mesh-wide membership cascade. */
canManage: boolean;
}
@@ -1,8 +1,8 @@
/**
* Render-gate coverage for the alias-detail Remove control and transport line.
*
* Removing a route opts its owning stack out of the mesh, an admin-only mutation
* (POST /api/mesh/nodes/:id/stacks/:stack/opt-out requires admin). This locks the
* Removing a route opts its owning stack out of the mesh, an Admin-only cascade.
* This locks the
* matching UI gate: a manager sees "Remove from mesh", a non-manager does not,
* while the read-only route detail stays available to both. It also pins the
* transport line to the node's actual transport so a proxy peer never reports a
@@ -15,7 +15,7 @@ interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
alias: string | null;
/** Removing a route opts its owning stack out of the mesh; admin-only, mirrors the backend gate. */
/** Whether the user may start the mesh-wide opt-out cascade. */
canManage: boolean;
status: MeshNodeStatus[];
aliases: MeshAlias[];
@@ -20,6 +20,7 @@ interface Props {
onTestUpstream: (alias: string) => Promise<void>;
onChanged: () => void;
canManage: boolean;
canManageMembership?: boolean;
}
const REVERSE_BRIDGE: Record<MeshNodeStatus['reverseCallbackStatus'], RoutingNodeCardMeta['reverseBridge']> = {
@@ -60,7 +61,7 @@ function buildFooterContext(
}
export function RoutingNodeCard({
status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage,
status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage, canManageMembership = canManage,
}: Props) {
const [toggling, setToggling] = useState(false);
const [testingAlias, setTestingAlias] = useState<string | null>(null);
@@ -200,6 +201,7 @@ export function RoutingNodeCard({
footerContext={footerContext}
offlineReason={status.reachableReason}
canManage={canManage}
canManageMembership={canManageMembership}
/>
);
}
+13 -8
View File
@@ -39,7 +39,10 @@ function readStoredEdgeMode(): MeshGraphEdgeMode {
}
}
export function RoutingTab({ canManage }: { canManage: boolean }) {
export function RoutingTab({ canManageNode, canManageMembership }: {
canManageNode: (nodeId: number) => boolean;
canManageMembership: boolean;
}) {
const [status, setStatus] = useState<MeshNodeStatus[]>([]);
const [localDataPlane, setLocalDataPlane] = useState<MeshDataPlaneStatus | null>(null);
const [aliases, setAliases] = useState<MeshAlias[]>([]);
@@ -177,13 +180,14 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
onShowAlias={(alias) => setRouteDetailAlias(alias)}
onTestUpstream={testUpstream}
onChanged={() => { void refresh(); }}
canManage={canManage}
canManage={canManageNode(s.nodeId)}
canManageMembership={canManageMembership}
/>
))}
</div>
</div>
<SheetsRoot
canManage={canManage}
canManageMembership={canManageMembership}
optInNode={optInNode} setOptInNode={setOptInNode}
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
@@ -234,7 +238,8 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
onShowAlias={(alias) => setRouteDetailAlias(alias)}
onTestUpstream={testUpstream}
onChanged={() => { void refresh(); }}
canManage={canManage}
canManage={canManageNode(s.nodeId)}
canManageMembership={canManageMembership}
/>
))}
</div>
@@ -247,7 +252,7 @@ export function RoutingTab({ canManage }: { canManage: boolean }) {
/>
)}
<SheetsRoot
canManage={canManage}
canManageMembership={canManageMembership}
optInNode={optInNode} setOptInNode={setOptInNode}
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
@@ -287,7 +292,7 @@ function RoutingMasthead({ meshedNodes, reachableNodes, totalAliases, onShowActi
}
function SheetsRoot(props: {
canManage: boolean;
canManageMembership: boolean;
optInNode: { id: number; name: string } | null;
setOptInNode: (v: { id: number; name: string } | null) => void;
diagnosticsNode: { id: number; name: string } | null;
@@ -313,7 +318,7 @@ function SheetsRoot(props: {
onOpenChange={(open) => { if (!open) props.setOptInNode(null); }}
nodeId={optInNode.id}
nodeName={optInNode.name}
canManage={props.canManage}
canManage={props.canManageMembership}
onChanged={props.onChanged}
/>
)}
@@ -328,7 +333,7 @@ function SheetsRoot(props: {
open={!!props.routeDetailAlias}
onOpenChange={(open) => { if (!open) props.setRouteDetailAlias(null); }}
alias={props.routeDetailAlias}
canManage={props.canManage}
canManage={props.canManageMembership}
status={props.status}
aliases={props.aliases}
onChanged={props.onChanged}
@@ -57,7 +57,8 @@ const EMPTY_FORM: PolicyFormState = {
* mirroring how the rest of the fleet-governance UI behaves.
*/
export function ScanPolicyManager() {
const { isAdmin } = useAuth();
const { isAdmin, can } = useAuth();
const canManagePolicies = can('stack:edit');
const { activeNode } = useNodes();
const isRemote = activeNode?.type === 'remote';
const { status: trivy, refresh: refreshTrivy } = useTrivyStatus();
@@ -264,7 +265,7 @@ export function ScanPolicyManager() {
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h3 className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">Deploy enforcement policies</h3>
{isAdmin && !isRemote && !isReplica && (
{canManagePolicies && !isRemote && !isReplica && (
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add policy
@@ -387,7 +388,7 @@ export function ScanPolicyManager() {
</Badge>
)}
</div>
{isAdmin && !isReplica && (
{canManagePolicies && !isReplica && (
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
@@ -28,7 +28,7 @@ function jsonResponse(status: number, body: unknown): Response {
}
function setup() {
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true, can: () => true } as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType<typeof NodeContext.useNodes>);
vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({
status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, preDeployScanAdvisory: false, cveIntelEnabled: true, busy: false },
@@ -35,7 +35,8 @@ interface MisconfigAckPanelProps {
}
export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
const { isAdmin } = useAuth();
const { can } = useAuth();
const canManage = can('stack:edit');
const [rows, setRows] = useState<MisconfigAcknowledgement[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -179,7 +180,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
title="Misconfig acknowledgements"
subtitle="Accept known-benign misconfigurations so they stop triggering alerts. Acknowledgements apply at read time across the fleet and never modify stored scan data."
action={
isAdmin && !isReplica ? (
canManage && !isReplica ? (
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add acknowledgement
@@ -255,7 +256,7 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
by {row.created_by} · expires {formatExpiry(row)}
</div>
</div>
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
{canManage && !isReplica && row.replicated_from_control === 0 && (
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
@@ -52,7 +52,9 @@ interface SuppressionsPanelProps {
}
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
const { isAdmin } = useAuth();
const { can } = useAuth();
const canRead = can('stack:read');
const canManage = can('stack:edit');
const [rows, setRows] = useState<CveSuppression[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -228,16 +230,20 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
title="CVE suppressions"
subtitle="Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across the fleet and never modify stored scan data."
action={
isAdmin && !isReplica ? (
!isReplica && (canRead || canManage) ? (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={handleExportVex}>
<Download className="w-4 h-4 mr-1.5" />
Export VEX
</Button>
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add suppression
</Button>
{canRead && (
<Button size="sm" variant="outline" onClick={handleExportVex}>
<Download className="w-4 h-4 mr-1.5" />
Export VEX
</Button>
)}
{canManage && (
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add suppression
</Button>
)}
</div>
) : undefined
}
@@ -325,7 +331,7 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
by {row.created_by} - expires {formatExpiry(row)}
</div>
</div>
{isAdmin && !isReplica && row.replicated_from_control === 0 && (
{canManage && !isReplica && row.replicated_from_control === 0 && (
<div className="flex items-center gap-1 shrink-0">
<TooltipProvider>
<Tooltip>
@@ -25,7 +25,7 @@ vi.mock('@/components/ui/toast-store', () => ({
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
useAuth: () => ({ isAdmin: true, can: () => true }),
}));
import { apiFetch } from '@/lib/api';
@@ -26,7 +26,7 @@ vi.mock('@/components/ui/toast-store', () => ({
}));
vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ isAdmin: true }),
useAuth: () => ({ isAdmin: true, can: () => true }),
}));
import { apiFetch } from '@/lib/api';
@@ -48,6 +48,8 @@ export interface RoutingNodeCardProps {
* management view it did not intend.
*/
canManage: boolean;
/** Whether mesh membership actions may start their fleet-wide cascade. */
canManageMembership?: boolean;
}
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
@@ -89,6 +91,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
onAddStack, onRetry, footerContext, offlineReason,
canManage,
} = props;
const canManageMembership = props.canManageMembership ?? canManage;
const [density] = useDensity();
const compact = density === 'compact';
@@ -120,6 +123,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
onToggleEnabled={onToggleEnabled}
onShowDiagnostics={onShowDiagnostics}
canManage={canManage}
canManageMembership={canManageMembership}
footerContext={footerContext}
onAddStack={onAddStack}
onRetry={onRetry}
@@ -137,6 +141,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
onToggleEnabled={onToggleEnabled}
onShowDiagnostics={onShowDiagnostics}
canManage={canManage}
canManageMembership={canManageMembership}
aliases={aliases}
onShowAlias={onShowAlias}
onTestAlias={onTestAlias}
@@ -161,6 +166,7 @@ interface BodyChrome {
onToggleEnabled: (next: boolean) => void;
onShowDiagnostics: () => void;
canManage: boolean;
canManageMembership: boolean;
footerContext: string;
onAddStack?: () => void;
onRetry?: () => void;
@@ -179,7 +185,7 @@ function ComfortableBody(props: ComfortableProps) {
crumb, name, isLocal, chip, meta, nodeState, isEnabled,
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
aliases, onShowAlias, onTestAlias, onAddStack, onRetry, footerContext, offlineReason,
canManage,
canManage, canManageMembership,
} = props;
const published = aliases.filter((a) => a.kind === 'alias').length;
const showAliases = aliases.length > 0;
@@ -228,7 +234,7 @@ function ComfortableBody(props: ComfortableProps) {
onShowAlias={onShowAlias}
onTestAlias={onTestAlias}
onAddStack={onAddStack}
canManage={canManage}
canManage={canManageMembership}
/>
: <EmptyState
nodeState={nodeState}
@@ -238,6 +244,7 @@ function ComfortableBody(props: ComfortableProps) {
onRetry={onRetry}
onToggleEnabled={onToggleEnabled}
canManage={canManage}
canManageMembership={canManageMembership}
/>}
</div>
@@ -250,7 +257,7 @@ function CompactBody(props: BodyChrome) {
const {
name, isLocal, chip, meta, nodeState, isEnabled,
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
footerContext, onAddStack, onRetry, canManage,
footerContext, onAddStack, onRetry, canManage, canManageMembership,
} = props;
return (
@@ -314,6 +321,7 @@ function CompactBody(props: BodyChrome) {
onRetry={onRetry}
onToggleEnabled={onToggleEnabled}
canManage={canManage}
canManageMembership={canManageMembership}
/>
</>
);
@@ -478,14 +486,15 @@ interface EmptyStateProps {
onRetry?: () => void;
onToggleEnabled: (next: boolean) => void;
canManage: boolean;
canManageMembership: boolean;
}
function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled, canManage }: EmptyStateProps) {
function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled, canManage, canManageMembership }: EmptyStateProps) {
const { headline, sub, cta } = emptyStateCopy(nodeState, name, offlineReason);
// The idle and meshed CTAs (enable mesh, add stack) are management actions
// the backend gates on the admin role, so a non-admin viewer sees a hint
// instead. The degraded/offline retry is a read-only refresh and stays.
// Enabling a node uses node:manage. Adding a stack starts an Admin-only
// mesh membership cascade. The degraded/offline retry stays available.
const isManagementState = nodeState === 'idle' || nodeState === 'meshed';
const canManageState = nodeState === 'meshed' ? canManageMembership : canManage;
// `connecting` is transient while the mesh bridge dials; show the headline
// only, with no retry button (it clears on its own once the bridge is up).
const isConnecting = nodeState === 'connecting';
@@ -507,7 +516,7 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog
</Button>
);
if (isConnecting) action = null;
else if (!canManage && isManagementState) {
else if (!canManageState && isManagementState) {
action = (
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
Managing the mesh requires an administrator.
@@ -546,13 +555,12 @@ function ctaToneFor(state: RoutingNodeState): string {
return CTA_TONE[state];
}
// Management CTAs (enable mesh / add stack) need admin; the read-only retry on a
// degraded/offline node stays available to everyone. `meshed` always offers "add
// stack" to admins so a node with aliases is never a dead end, and the transient
// `connecting` state shows no CTA.
function shouldShowCta(state: RoutingNodeState, canManage: boolean): boolean {
// Enabling mesh uses node management, while adding a stack starts an Admin-only
// membership cascade. Read-only retry stays available to everyone.
function shouldShowCta(state: RoutingNodeState, canManage: boolean, canManageMembership: boolean): boolean {
if (state === 'connecting') return false;
if (state === 'idle' || state === 'meshed') return canManage;
if (state === 'idle') return canManage;
if (state === 'meshed') return canManageMembership;
return true;
}
@@ -615,10 +623,11 @@ interface CompactFooterProps {
onRetry?: () => void;
onToggleEnabled: (next: boolean) => void;
canManage: boolean;
canManageMembership: boolean;
}
function CompactFooter({ context, nodeState, name, onAddStack, onRetry, onToggleEnabled, canManage }: CompactFooterProps) {
const showCta = shouldShowCta(nodeState, canManage);
function CompactFooter({ context, nodeState, name, onAddStack, onRetry, onToggleEnabled, canManage, canManageMembership }: CompactFooterProps) {
const showCta = shouldShowCta(nodeState, canManage, canManageMembership);
const { cta } = emptyStateCopy(nodeState, name);
const handleClick = () => {
if (nodeState === 'idle') onToggleEnabled(true);