diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index ed864119..818b3e9c 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -22,7 +22,6 @@ import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { formatVersion } from '@/lib/version'; import { useAuth } from '@/context/AuthContext'; -import { useLicense } from '@/context/LicenseContext'; import { useNodes, type Node } from '@/context/NodeContext'; import { cordonNode, uncordonNode } from '@/lib/nodesApi'; import { UpdateStatusBadge } from './UpdateStatusBadge'; @@ -77,15 +76,13 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, const [cordonSubmitting, setCordonSubmitting] = useState(false); const { isAdmin, can } = useAuth(); - const { isPaid } = useLicense(); 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); - // Cordon requires the paid tier AND node:manage, matching the backend guard - // (requirePermission('node:manage','node',id) + requirePaid). - const canCordon = isPaid && can('node:manage', 'node', String(node.id)); + // Cordon is permission-gated only (node:manage), matching the backend route guard. + const canCordon = can('node:manage', 'node', String(node.id)); const nodeMuteActions = useNodeMuteActions( node.id, node.name, diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index 94ce2185..a94ee6ee 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -2,11 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -const useLicenseMock = vi.fn(); const useAuthMock = vi.fn(); const useNodesMock = vi.fn(); -vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock() })); vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() })); vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() })); vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); @@ -36,7 +34,6 @@ function baseProps(node: FleetNode) { beforeEach(() => { useNodesMock.mockReturnValue({ nodes: [], hasCapability: vi.fn(() => false) }); useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) }); - useLicenseMock.mockReturnValue({ isPaid: false }); }); afterEach(() => vi.clearAllMocks()); @@ -55,31 +52,9 @@ describe('NodeCard', () => { expect(screen.queryByText('Running')).not.toBeInTheDocument(); }); - it('hides the actions menu for a free-tier user', () => { - useLicenseMock.mockReturnValue({ isPaid: false }); - render(); - expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument(); - }); - - it('hides the actions menu for a Community admin with node:manage when cordon requires Admiral', () => { - useLicenseMock.mockReturnValue({ isPaid: false }); - render(); - // Cordon is Admiral-only; without edit/delete props, no menu items are available to Community. - expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument(); - }); - - it('exposes the actions menu (cordon entry point) for a paid admin', () => { - useLicenseMock.mockReturnValue({ isPaid: true }); - render(); - // With no edit/delete affordances wired, the menu renders iff cordon is - // allowed: isPaid && can('node:manage'). The admin's can() returns true. - expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument(); - }); - - it('exposes the cordon control for an Admiral node-admin via the node:manage permission', async () => { + it('exposes the actions menu and Cordon node for a user with node:manage', async () => { const can = vi.fn((action: string) => action === 'node:manage'); useAuthMock.mockReturnValue({ isAdmin: false, can }); - useLicenseMock.mockReturnValue({ isPaid: true }); render(); await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); @@ -87,18 +62,15 @@ describe('NodeCard', () => { expect(can).toHaveBeenCalledWith('node:manage', 'node', '2'); }); - it('hides the cordon control from a paid user lacking node:manage', () => { + it('hides the cordon control from a user lacking node:manage', () => { useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); - useLicenseMock.mockReturnValue({ isPaid: true }); render(); - // The paid tier alone must not surface cordon to a deployer/viewer/auditor. expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument(); }); it('shows Uncordon when the node is already cordoned', async () => { const can = vi.fn((action: string) => action === 'node:manage'); useAuthMock.mockReturnValue({ isAdmin: false, can }); - useLicenseMock.mockReturnValue({ isPaid: true }); render(); await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); @@ -111,13 +83,13 @@ describe('NodeCard', () => { }; it('renders the update button for an admin when an update is available', () => { - useAuthMock.mockReturnValue({ isAdmin: true }); + useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) }); render(); expect(screen.getByRole('button', { name: /Update/ })).toBeInTheDocument(); }); it('hides the update button and shows Pinned when updateBlocked', () => { - useAuthMock.mockReturnValue({ isAdmin: true }); + useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) }); render(