fix(mesh): hide node and stack management controls from non-admins (#1284)

* fix(mesh): hide node and stack management controls from non-admins

The Routing tab rendered the per-node mesh enable/disable toggle and the
stack opt-in/opt-out controls for any Admiral-tier user, but those backend
routes require the admin role. A non-admin viewer on an Admiral instance
saw controls that returned 403.

Thread a canManage flag (true only for admins) from the Fleet view into
the Routing tab, its node cards, and the opt-in sheet so non-admins get a
read-only Routing tab: the enable/disable toggle, add-stack, and
opt-in/opt-out controls are hidden, while status, aliases, topology,
activity, diagnostics, and the alias test probe stay available. This
mirrors the Federation tab's existing read-only treatment for non-admins.

Add backend route-gating tests covering the tier and admin-role guards on
every mesh route, and frontend render-gate tests for the node card and the
opt-in sheet in both density layouts.

* refactor(mesh): require canManage on the routing-node-card primitive

Remove the permissive `canManage = true` default on the shared
routing-node-card primitive so a new call site cannot render the
management controls without an explicit decision. Every current caller
already passes the flag; the type now enforces it. Drop the omitted-prop
test, which covered a state the compiler now prevents.
This commit is contained in:
Anso
2026-06-02 16:09:25 -04:00
committed by GitHub
parent 02f98ab90a
commit c82a39c65a
8 changed files with 417 additions and 34 deletions
@@ -0,0 +1,105 @@
/**
* Render-gate coverage for the routing-node-card `canManage` prop.
*
* Enabling/disabling mesh on a node and opting a stack in are admin-only on the
* backend. This locks the matching UI gate: a manager sees the enable/disable
* toggle and the enable/add CTAs, a non-manager sees neither (just a hint) while
* the read-only affordances, diagnostics in particular, stay available. Without
* this the card can drift back to rendering a control the API answers with 403.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { RoutingNodeCard, type RoutingNodeCardProps } from '@/components/ui/routing-node-card';
function renderCard(overrides: Partial<RoutingNodeCardProps> = {}) {
const props: RoutingNodeCardProps = {
crumb: ['Routing', 'Node', 'node-alpha'],
name: 'node-alpha',
nodeState: 'idle',
meta: { pilotConnected: true, reverseBridge: 'na', stacks: 0, aliases: 0 },
aliases: [],
onToggleEnabled: vi.fn(),
onShowDiagnostics: vi.fn(),
onAddStack: vi.fn(),
onRetry: vi.fn(),
footerContext: 'Mesh off',
// canManage is required on the primitive; tests override it per case.
canManage: true,
...overrides,
};
return render(<RoutingNodeCard {...props} />);
}
describe('routing-node-card canManage gate', () => {
it('shows the enable toggle and the enable CTA for a manager', () => {
renderCard({ nodeState: 'idle', canManage: true });
expect(screen.getByRole('switch')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Enable mesh on node-alpha/i })).toBeInTheDocument();
expect(screen.queryByText(/Managing the mesh requires an administrator/i)).not.toBeInTheDocument();
});
it('hides the toggle and enable CTA for a non-manager but keeps diagnostics', () => {
renderCard({ nodeState: 'idle', canManage: false });
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Enable mesh on/i })).not.toBeInTheDocument();
expect(screen.getByText(/Managing the mesh requires an administrator/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Diagnostics/i })).toBeInTheDocument();
});
it('hides the add-stack CTA for a non-manager on a meshed node', () => {
renderCard({
nodeState: 'meshed',
meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 },
canManage: false,
});
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
expect(screen.queryByText(/Add stack to mesh/i)).not.toBeInTheDocument();
expect(screen.getByText(/Managing the mesh requires an administrator/i)).toBeInTheDocument();
});
it('keeps the retry CTA for a non-manager on a degraded node', () => {
// Retry is a read-only refresh, not a management action, so it must
// survive the gate. A regression dropping the management-state check
// would hide it for non-admins.
renderCard({ nodeState: 'degraded', canManage: false });
expect(screen.getByRole('button', { name: /Retry now/i })).toBeInTheDocument();
expect(screen.queryByText(/Managing the mesh requires an administrator/i)).not.toBeInTheDocument();
});
it('keeps the retry CTA for a non-manager on an offline node', () => {
renderCard({ nodeState: 'offline', canManage: false });
expect(screen.getByRole('button', { name: /Retry now/i })).toBeInTheDocument();
});
});
describe('routing-node-card canManage gate (compact density)', () => {
beforeEach(() => {
window.localStorage.setItem('sencho.appearance.density', 'compact');
});
afterEach(() => {
window.localStorage.removeItem('sencho.appearance.density');
});
it('hides the toggle for a non-manager on a meshed node', () => {
renderCard({
nodeState: 'meshed',
meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 },
canManage: false,
});
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
});
it('shows the toggle for a manager on a meshed node', () => {
renderCard({
nodeState: 'meshed',
meta: { pilotConnected: true, reverseBridge: 'up', stacks: 0, aliases: 0 },
canManage: true,
});
expect(screen.getByRole('switch')).toBeInTheDocument();
});
it('keeps the retry CTA for a non-manager on an offline node', () => {
renderCard({ nodeState: 'offline', canManage: false });
expect(screen.getByRole('button', { name: /Retry now/i })).toBeInTheDocument();
});
});
@@ -40,6 +40,15 @@ export interface RoutingNodeCardProps {
footerContext: string;
/** Offline-state reason copy; falls back to a generic line. */
offlineReason?: string | null;
/**
* When false, the management affordances (enable/disable toggle and the
* enable-mesh / add-stack empty-state CTAs) are hidden so a viewer without
* the admin role gets a read-only card. Read-only affordances (diagnostics,
* alias probe, retry) stay available. Required, with no default: a caller
* must state the gate explicitly so a new call site cannot fall open to a
* management view it did not intend.
*/
canManage: boolean;
}
const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]';
@@ -76,6 +85,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
crumb, name, isLocal, nodeState, meta, aliases,
onToggleEnabled, onShowDiagnostics, onShowAlias, onTestAlias,
onAddStack, onRetry, footerContext, offlineReason,
canManage,
} = props;
const [density] = useDensity();
const compact = density === 'compact';
@@ -107,6 +117,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
diagnosticsDisabled={diagnosticsDisabled}
onToggleEnabled={onToggleEnabled}
onShowDiagnostics={onShowDiagnostics}
canManage={canManage}
footerContext={footerContext}
aliasesEmpty={aliases.length === 0}
onAddStack={onAddStack}
@@ -124,6 +135,7 @@ export function RoutingNodeCard(props: RoutingNodeCardProps) {
diagnosticsDisabled={diagnosticsDisabled}
onToggleEnabled={onToggleEnabled}
onShowDiagnostics={onShowDiagnostics}
canManage={canManage}
aliases={aliases}
onShowAlias={onShowAlias}
onTestAlias={onTestAlias}
@@ -147,6 +159,7 @@ interface BodyChrome {
diagnosticsDisabled: boolean;
onToggleEnabled: (next: boolean) => void;
onShowDiagnostics: () => void;
canManage: boolean;
footerContext: string;
onAddStack?: () => void;
onRetry?: () => void;
@@ -169,6 +182,7 @@ function ComfortableBody(props: ComfortableProps) {
crumb, name, isLocal, chip, meta, nodeState, isEnabled,
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
aliases, onShowAlias, onTestAlias, onAddStack, onRetry, footerContext, offlineReason,
canManage,
} = props;
const published = aliases.filter((a) => a.kind === 'alias').length;
const showAliases = aliases.length > 0;
@@ -207,6 +221,7 @@ function ComfortableBody(props: ComfortableProps) {
diagnosticsDisabled={diagnosticsDisabled}
onToggleEnabled={onToggleEnabled}
onShowDiagnostics={onShowDiagnostics}
canManage={canManage}
/>
<div className="px-4 py-3 pl-5">
@@ -224,6 +239,7 @@ function ComfortableBody(props: ComfortableProps) {
onAddStack={onAddStack}
onRetry={onRetry}
onToggleEnabled={onToggleEnabled}
canManage={canManage}
/>}
</div>
@@ -236,7 +252,7 @@ function CompactBody(props: CompactProps) {
const {
name, isLocal, chip, meta, nodeState, isEnabled,
toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics,
footerContext, aliasesEmpty, onAddStack, onRetry,
footerContext, aliasesEmpty, onAddStack, onRetry, canManage,
} = props;
return (
@@ -254,11 +270,13 @@ function CompactBody(props: CompactProps) {
{chip.label}
</span>
<div className="ml-auto flex items-center gap-1.5">
<TogglePill
checked={isEnabled}
disabled={toggleDisabled}
onChange={onToggleEnabled}
/>
{canManage && (
<TogglePill
checked={isEnabled}
disabled={toggleDisabled}
onChange={onToggleEnabled}
/>
)}
<Button
variant="outline"
size="sm"
@@ -303,6 +321,7 @@ function CompactBody(props: CompactProps) {
onAddStack={onAddStack}
onRetry={onRetry}
onToggleEnabled={onToggleEnabled}
canManage={canManage}
/>
</>
);
@@ -335,20 +354,23 @@ interface ToolbarProps {
diagnosticsDisabled: boolean;
onToggleEnabled: (next: boolean) => void;
onShowDiagnostics: () => void;
canManage: boolean;
}
function Toolbar({ chip, isEnabled, toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics }: ToolbarProps) {
function Toolbar({ chip, isEnabled, toggleDisabled, diagnosticsDisabled, onToggleEnabled, onShowDiagnostics, canManage }: ToolbarProps) {
return (
<div className="flex items-center gap-2 px-4 py-2 pl-5 border-y border-card-border/40 bg-card/60">
<span className={cn(KICKER, 'inline-flex items-center px-1.5 py-0.5 rounded-sm border', chip.tone)}>
{chip.label}
</span>
<div className="flex-1" />
<TogglePill
checked={isEnabled}
disabled={toggleDisabled}
onChange={onToggleEnabled}
/>
{canManage && (
<TogglePill
checked={isEnabled}
disabled={toggleDisabled}
onChange={onToggleEnabled}
/>
)}
<Button
variant="outline"
size="sm"
@@ -449,10 +471,15 @@ interface EmptyStateProps {
onAddStack?: () => void;
onRetry?: () => void;
onToggleEnabled: (next: boolean) => void;
canManage: boolean;
}
function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled }: EmptyStateProps) {
function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onToggleEnabled, canManage }: 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.
const isManagementState = nodeState === 'idle' || nodeState === 'meshed';
const handleClick = () => {
if (nodeState === 'idle') onToggleEnabled(true);
@@ -468,14 +495,20 @@ function EmptyState({ nodeState, name, offlineReason, onAddStack, onRetry, onTog
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
{sub}
</div>
<Button
variant="outline"
size="sm"
onClick={handleClick}
className={cn('mt-1', ctaToneFor(nodeState))}
>
{ctaIconFor(nodeState)}{cta}
</Button>
{!canManage && isManagementState ? (
<div className="font-mono text-[11px] leading-snug text-stat-subtitle">
Managing the mesh requires an administrator.
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={handleClick}
className={cn('mt-1', ctaToneFor(nodeState))}
>
{ctaIconFor(nodeState)}{cta}
</Button>
)}
</div>
);
}
@@ -546,10 +579,14 @@ interface CompactFooterProps {
onAddStack?: () => void;
onRetry?: () => void;
onToggleEnabled: (next: boolean) => void;
canManage: boolean;
}
function CompactFooter({ context, nodeState, name, aliasesEmpty, onAddStack, onRetry, onToggleEnabled }: CompactFooterProps) {
const showCta = nodeState !== 'meshed' || aliasesEmpty;
function CompactFooter({ context, nodeState, name, aliasesEmpty, onAddStack, onRetry, onToggleEnabled, canManage }: CompactFooterProps) {
// Hide the management CTAs (enable mesh / add stack) for non-admins; the
// read-only retry on a degraded/offline node stays available.
const isManagementState = nodeState === 'idle' || nodeState === 'meshed';
const showCta = (nodeState !== 'meshed' || aliasesEmpty) && (canManage || !isManagementState);
const { cta } = emptyStateCopy(nodeState, name);
const handleClick = () => {
if (nodeState === 'idle') onToggleEnabled(true);