mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
chore(ui): hide Mesh, Fleet Secrets, and Host Console behind experimental discovery (#1624)
Gate Routing, Secrets, Host Console, and Mesh dashboard/settings surfaces on the existing useExperimental readiness flag so immature operator surfaces stay out of the default UI while paid and admin backend gates remain unchanged.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Suspense, lazy, type ReactNode } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useExperimental } from '@/hooks/useExperimental';
|
||||
import { PaidGate } from '../PaidGate';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
import { HubOnlyGate } from '../HubOnlyGate';
|
||||
@@ -138,6 +139,7 @@ export function ViewRouter({
|
||||
isFileLoading,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
if (activeView === 'settings') {
|
||||
return (
|
||||
<SettingsPage
|
||||
@@ -166,9 +168,11 @@ export function ViewRouter({
|
||||
);
|
||||
}
|
||||
if (activeView === 'host-console') {
|
||||
// Mirror the backend RBAC gate (system:console, admin-only). The nav
|
||||
// item is already admin-gated; this stops a non-admin who reaches the
|
||||
// view another way from mounting a console that the server will 403.
|
||||
// Discovery + paid/RBAC: hide until experimental discovery is on,
|
||||
// then mirror backend gates (system:console admin-only + PaidGate +
|
||||
// capability). Nav is already gated the same way; this stops a
|
||||
// deep link from mounting a console the operator cannot use.
|
||||
if (!experimentalReady || !experimental) return null;
|
||||
if (!can('system:console')) return null;
|
||||
return (
|
||||
<PaidGate>
|
||||
|
||||
@@ -10,6 +10,11 @@ vi.mock('@/context/AuthContext');
|
||||
vi.mock('@/context/LicenseContext');
|
||||
vi.mock('@/context/NodeContext');
|
||||
|
||||
const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true }));
|
||||
vi.mock('@/hooks/useExperimental', () => ({
|
||||
useExperimental: () => useExperimentalMock(),
|
||||
}));
|
||||
|
||||
function mockActiveNode(type: 'local' | 'remote' | null) {
|
||||
vi.mocked(NodeContext.useNodes).mockReturnValue({
|
||||
activeNode: type === null ? null : { type, id: 1, name: 'n' },
|
||||
@@ -71,6 +76,7 @@ describe('useViewNavigationState', () => {
|
||||
beforeEach(() => {
|
||||
mockCommunityUser();
|
||||
mockActiveNode('local');
|
||||
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
|
||||
});
|
||||
|
||||
// ── initial state ──────────────────────────────────────────────────────────
|
||||
@@ -419,4 +425,54 @@ describe('useViewNavigationState', () => {
|
||||
expect(result.current.activeView).toBe('security');
|
||||
expect(result.current.securityTab).toBe('overview');
|
||||
});
|
||||
|
||||
// ── experimental discovery ─────────────────────────────────────────────────
|
||||
|
||||
it('hides Console from nav for a paid admin when experimental discovery is off', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
|
||||
});
|
||||
|
||||
it('hides Console from nav while experimental metadata is still loading', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).not.toContain('host-console');
|
||||
});
|
||||
|
||||
it('does not normalize a host-console deep link before experimental readiness', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
const { result } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
|
||||
act(() => result.current.setActiveView('host-console'));
|
||||
expect(result.current.activeView).toBe('host-console');
|
||||
expect(onNavigateToDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps host-console selected when delayed experimental resolves enabled', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
|
||||
act(() => result.current.setActiveView('host-console'));
|
||||
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
|
||||
rerender();
|
||||
expect(result.current.activeView).toBe('host-console');
|
||||
expect(onNavigateToDashboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes host-console once when experimental resolves disabled', () => {
|
||||
mockPaidAdmin();
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false });
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard }));
|
||||
act(() => result.current.setActiveView('host-console'));
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||
rerender();
|
||||
expect(result.current.activeView).toBe('dashboard');
|
||||
expect(onNavigateToDashboard).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ function makeReachCtx(over: Partial<ReachabilityContext> = {}): ReachabilityCont
|
||||
containerLabelsEnabled: true,
|
||||
permissionsStatus: 'ready',
|
||||
licenseStatus: 'ready',
|
||||
experimental: true,
|
||||
experimentalReady: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
normalizeHiddenView,
|
||||
type ReachabilityContext,
|
||||
} from '@/lib/routing/reachability';
|
||||
import { useExperimental } from '@/hooks/useExperimental';
|
||||
|
||||
export type { ActiveView };
|
||||
export { HUB_ONLY_VIEWS };
|
||||
@@ -44,6 +45,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
const { isPaid, licenseStatus } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
|
||||
const initialRoute = readUrlRouteState();
|
||||
|
||||
@@ -65,7 +67,9 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
containerLabelsEnabled,
|
||||
permissionsStatus,
|
||||
licenseStatus,
|
||||
}), [isAdmin, isPaid, can, isRemote, hasFleetCapability, containerLabelsEnabled, permissionsStatus, licenseStatus]);
|
||||
experimental,
|
||||
experimentalReady,
|
||||
}), [isAdmin, isPaid, can, isRemote, hasFleetCapability, containerLabelsEnabled, permissionsStatus, licenseStatus, experimental, experimentalReady]);
|
||||
|
||||
const handleOpenSettings = useCallback((section?: SectionId) => {
|
||||
if (section) setSettingsSection(section);
|
||||
@@ -141,14 +145,17 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
if (!isViewHidden('scheduled-ops', reachCtx)) {
|
||||
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
||||
}
|
||||
if (!isViewHidden('host-console', reachCtx)) {
|
||||
// Visual discovery fail-closed: omit Console until /meta settles and the
|
||||
// flag is on. URL normalization still waits on experimentalReady inside
|
||||
// isViewHidden so enabled deep links are not rewritten during cold load.
|
||||
if (experimentalReady && experimental && !isViewHidden('host-console', reachCtx)) {
|
||||
items.push({ value: 'host-console', label: 'Console', icon: Terminal });
|
||||
}
|
||||
if (!isViewHidden('audit-log', reachCtx)) {
|
||||
items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
|
||||
}
|
||||
return items;
|
||||
}, [reachCtx]);
|
||||
}, [reachCtx, experimentalReady, experimental]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authzReady(reachCtx)) return;
|
||||
|
||||
Reference in New Issue
Block a user