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,155 @@
/**
* Gate coverage for the mesh router.
*
* Every /api/mesh route is tier-gated (requireAdmiral). The five operator
* mutations are additionally role-gated (requireAdmin): node enable/disable,
* stack opt-in/opt-out, and the override regen. The operator read routes
* (status, aliases, activity, diagnostics) stay reachable for any Admiral-tier
* user regardless of role, which is what lets a non-admin see a read-only
* Routing tab. The node-to-node routes that central calls over the proxy on the
* operator's behalf (local-override PUT/DELETE, alias test) are Admiral-gated
* but intentionally not admin-gated. These tests lock that split so the backend
* can never silently diverge from the matching frontend render gate (a button
* that 403s, or a feature an owner cannot see).
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let defaultNodeId: number;
function userToken(username: string): string {
const user = DatabaseService.getInstance().getUserByUsername(username);
if (!user) throw new Error(`missing test user ${username}`);
return jwt.sign({ username, role: user.role, tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '5m' });
}
function setTier(tier: 'community' | 'paid', variant: 'skipper' | 'admiral' | null): void {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue(variant);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ LicenseService } = await import('../services/LicenseService'));
const viewerHash = await bcrypt.hash('password123', 1);
DatabaseService.getInstance().addUser({ username: 'mesh-viewer', password_hash: viewerHash, role: 'viewer' });
defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1;
({ app } = await import('../index'));
});
beforeEach(() => {
// Default every test to a fully entitled Admiral instance; tier-rejection
// tests override this locally.
setTier('paid', 'admiral');
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
describe('mesh tier gate (requireAdmiral)', () => {
it('rejects Community tier with PAID_REQUIRED', async () => {
setTier('community', null);
const res = await request(app)
.get('/api/mesh/aliases')
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('rejects a paid non-Admiral variant with ADMIRAL_REQUIRED', async () => {
setTier('paid', 'skipper');
const res = await request(app)
.get('/api/mesh/aliases')
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
});
it('rejects Community tier on a mutation before the role gate runs', async () => {
setTier('community', null);
const res = await request(app)
.post('/api/mesh/regen-overrides')
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
});
describe('mesh read routes are visible to a non-admin Admiral user', () => {
it('returns aliases to a viewer', async () => {
const res = await request(app)
.get('/api/mesh/aliases')
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.aliases)).toBe(true);
});
it('returns activity to a viewer', async () => {
const res = await request(app)
.get('/api/mesh/activity')
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.events)).toBe(true);
});
it('returns status to a viewer', async () => {
const res = await request(app)
.get('/api/mesh/status')
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.nodes)).toBe(true);
});
});
describe('mesh mutation routes require the admin role (requireAdmin)', () => {
const mutationRoutes: { name: string; path: () => string }[] = [
{ name: 'POST /regen-overrides', path: () => '/api/mesh/regen-overrides' },
{ name: 'POST /nodes/:id/enable', path: () => `/api/mesh/nodes/${defaultNodeId}/enable` },
{ name: 'POST /nodes/:id/disable', path: () => `/api/mesh/nodes/${defaultNodeId}/disable` },
{ name: 'POST /nodes/:id/stacks/:stack/opt-in', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-in` },
{ name: 'POST /nodes/:id/stacks/:stack/opt-out', path: () => `/api/mesh/nodes/${defaultNodeId}/stacks/demo/opt-out` },
];
for (const route of mutationRoutes) {
it(`${route.name} rejects a non-admin Admiral user with ADMIN_REQUIRED`, async () => {
const res = await request(app)
.post(route.path())
.set('Authorization', `Bearer ${userToken('mesh-viewer')}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
}
it('lets an Admiral admin pass both gates on regen-overrides', async () => {
const res = await request(app)
.post('/api/mesh/regen-overrides')
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('regenerated');
});
it('lets an Admiral admin past both gates on a node mutation (not gate-rejected)', async () => {
// Locks the guard order (tier before role) for a mutation other than
// regen-overrides: an admin must never be rejected by either gate. The
// handler may still 4xx/5xx for other reasons in the test environment;
// only the gate codes are asserted absent.
const res = await request(app)
.post(`/api/mesh/nodes/${defaultNodeId}/enable`)
.set('Authorization', `Bearer ${userToken(TEST_USERNAME)}`);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(res.body.code).not.toBe('ADMIRAL_REQUIRED');
expect(res.body.code).not.toBe('ADMIN_REQUIRED');
});
});
+1 -1
View File
@@ -210,7 +210,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
{isAdmiral && (
<TabsContent value="routing">
<AdmiralGate>
<RoutingTab />
<RoutingTab canManage={isAdmin} />
</AdmiralGate>
</TabsContent>
)}
@@ -0,0 +1,73 @@
/**
* 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
* non-manager sees the membership read-only with a hint and still gets the
* read-only topology affordance. The read-only branch must never issue the
* admin-only mutation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import type { MeshStackEntry } from '@/types/mesh';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
import { apiFetch } from '@/lib/api';
import { MeshOptInSheet } from './MeshOptInSheet';
const STACKS: MeshStackEntry[] = [
{ name: 'web', optedIn: true },
{ name: 'db', optedIn: false },
];
beforeEach(() => {
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ stacks: STACKS }),
} as unknown as Response);
});
function renderSheet(canManage: boolean) {
return render(
<MeshOptInSheet
open={true}
onOpenChange={() => {}}
nodeId={1}
nodeName="node-alpha"
onChanged={() => {}}
onViewTopology={() => {}}
canManage={canManage}
/>,
);
}
describe('MeshOptInSheet canManage gate', () => {
it('shows opt-in/out controls for a manager', async () => {
renderSheet(true);
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();
});
it('renders the membership read-only for a non-manager', async () => {
renderSheet(false);
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();
// Topology is read-only and stays available for opted-in stacks.
expect(screen.getByRole('button', { name: /View topology for web/i })).toBeInTheDocument();
// The read-only branch must never issue an opt-in/opt-out request.
expect(vi.mocked(apiFetch)).not.toHaveBeenCalledWith(
expect.stringContaining('/opt-'),
expect.anything(),
);
});
});
@@ -14,9 +14,11 @@ interface Props {
nodeName: string;
onChanged: () => void;
onViewTopology?: (stack: string) => void;
/** Opt-in/out is admin-only on the backend; non-admins see the list read-only. */
canManage: boolean;
}
export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, onViewTopology }: Props) {
export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged, onViewTopology, canManage }: Props) {
const [stacks, setStacks] = useState<MeshStackEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -93,6 +95,7 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
<p className="text-sm text-stat-subtitle leading-snug">
Adding a stack lets its services be reached from other meshed stacks by hostname.
Toggling a stack triggers a redeploy on its node so the routing override applies.
{!canManage && ' Changing mesh membership requires an administrator.'}
</p>
{loading && (
@@ -132,13 +135,15 @@ export function MeshOptInSheet({ open, onOpenChange, nodeId, nodeName, onChanged
<Workflow className="w-3 h-3 mr-1" /> Topology
</Button>
)}
<Button
size="sm"
variant={stack.optedIn ? 'outline' : 'default'}
onClick={() => setConfirmStack(stack)}
>
{stack.optedIn ? 'Remove from mesh' : 'Add to mesh'}
</Button>
{canManage && (
<Button
size="sm"
variant={stack.optedIn ? 'outline' : 'default'}
onClick={() => setConfirmStack(stack)}
>
{stack.optedIn ? 'Remove from mesh' : 'Add to mesh'}
</Button>
)}
</div>
)}
</div>
@@ -18,6 +18,7 @@ interface Props {
onShowAlias: (alias: string) => void;
onTestUpstream: (alias: string) => Promise<void>;
onChanged: () => void;
canManage: boolean;
}
const REVERSE_BRIDGE: Record<MeshNodeStatus['reverseCallbackStatus'], RoutingNodeCardMeta['reverseBridge']> = {
@@ -62,7 +63,7 @@ function buildFooterContext(
}
export function RoutingNodeCard({
status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged,
status, aliases, onAddStack, onShowDiagnostics, onShowAlias, onTestUpstream, onChanged, canManage,
}: Props) {
const [toggling, setToggling] = useState(false);
const [testingAlias, setTestingAlias] = useState<string | null>(null);
@@ -175,6 +176,7 @@ export function RoutingNodeCard({
onRetry={onChanged}
footerContext={footerContext}
offlineReason={status.reachableReason}
canManage={canManage}
/>
);
}
+7 -1
View File
@@ -45,7 +45,7 @@ function readStoredEdgeMode(): MeshGraphEdgeMode {
}
}
export function RoutingTab() {
export function RoutingTab({ canManage }: { canManage: boolean }) {
const [status, setStatus] = useState<MeshNodeStatus[]>([]);
const [localDataPlane, setLocalDataPlane] = useState<MeshDataPlaneStatus | null>(null);
const [aliases, setAliases] = useState<MeshAlias[]>([]);
@@ -178,11 +178,13 @@ export function RoutingTab() {
onShowAlias={(alias) => setRouteDetailAlias(alias)}
onTestUpstream={testUpstream}
onChanged={() => { void refresh(); }}
canManage={canManage}
/>
))}
</div>
</div>
<SheetsRoot
canManage={canManage}
optInNode={optInNode} setOptInNode={setOptInNode}
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
@@ -235,6 +237,7 @@ export function RoutingTab() {
onShowAlias={(alias) => setRouteDetailAlias(alias)}
onTestUpstream={testUpstream}
onChanged={() => { void refresh(); }}
canManage={canManage}
/>
))}
</div>
@@ -247,6 +250,7 @@ export function RoutingTab() {
/>
)}
<SheetsRoot
canManage={canManage}
optInNode={optInNode} setOptInNode={setOptInNode}
diagnosticsNode={diagnosticsNode} setDiagnosticsNode={setDiagnosticsNode}
routeDetailAlias={routeDetailAlias} setRouteDetailAlias={setRouteDetailAlias}
@@ -288,6 +292,7 @@ function RoutingMasthead({ meshedNodes, reachableNodes, totalAliases, onShowActi
}
function SheetsRoot(props: {
canManage: boolean;
optInNode: { id: number; name: string } | null;
setOptInNode: (v: { id: number; name: string } | null) => void;
diagnosticsNode: { id: number; name: string } | null;
@@ -311,6 +316,7 @@ function SheetsRoot(props: {
onOpenChange={(open) => { if (!open) props.setOptInNode(null); }}
nodeId={optInNode.id}
nodeName={optInNode.name}
canManage={props.canManage}
onChanged={props.onChanged}
onViewTopology={(stack) => {
props.setTopologyStack({ nodeId: optInNode.id, nodeName: optInNode.name, stack });
@@ -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);