mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +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,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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user