mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user