Files
sencho/frontend/src/components/CapabilityGate.tsx
T
Anso 6b74767388 fix(frontend): short-circuit CapabilityGate and extract shared LockCard (#873)
CapabilityGate previously rendered the gated children behind a blur
filter and overlay pill when the active node lacked the required
capability. Combined with the lazy-loaded views from the recent
splitting work, this meant the chunk for FleetView, AuditLogView,
HostConsole, etc. fetched on click even when the user could not use
the feature, defeating the click-time IP protection the lazy split
was meant to deliver. CapabilityGate was the only always-leaking gate
in the app.

Replace the blurred-children render with a clean glass lock card that
explains the version mismatch ("Fleet Management is not available on
this node. <node> is running v0.42.0. Upgrade the node to use this
feature."). Children are no longer rendered, so the lazy children
never mount and no chunk fetch happens. All 13 consumers (5 full-page
views, 7 settings sections, 1 inline panel in ResourcesView) get the
new behavior automatically; no consumer-side changes required.

Extract a shared LockCard primitive used by both CapabilityGate and
the existing TierLockedCard inside settings/SectionGate. The two were
95% identical (same glass-card chrome, same icon framing, same text
hierarchy) and would have drifted as the design evolved. The shared
primitive accepts an icon, title, and body, with an optional className
for layout overrides; the inner geometry is fixed so every lock state
in the app shares the same visual rhythm.

PaidGate and AdmiralGate still fall through to "blurred preview with
small pill" after a user clicks Dismiss on their full-page upsell
(24h localStorage window). That post-dismissal click-time leak is
intentionally out of scope here; closing it would require either
removing the dismissal flow or replacing the blurred-children render
with a static placeholder, both UX decisions deserving their own PR.
2026-05-02 02:33:53 -04:00

44 lines
1.6 KiB
TypeScript

import { type ReactNode } from 'react';
import { Unplug } from 'lucide-react';
import { useNodes } from '@/context/NodeContext';
import type { Capability } from '@/lib/capabilities';
import { isValidVersion } from '@/lib/version';
import { LockCard } from './ui/LockCard';
interface CapabilityGateProps {
capability: Capability;
featureName?: string;
children: ReactNode;
}
/**
* Renders children only when the active node advertises the required
* capability. When it does not, returns a clean lock card explaining the
* version mismatch instead of rendering the gated UI.
*
* Short-circuiting is load-bearing: callers wrap CapabilityGate around
* lazy-loaded views, and rendering the gated children to "blur and
* overlay" them would still trigger the chunk fetch (and ship the JSX
* to anyone who opens DevTools). The lock card has no children
* dependency and adds no chunk weight, so a node that lacks the
* capability never downloads the gated module.
*/
export function CapabilityGate({ capability, featureName = 'This feature', children }: CapabilityGateProps) {
const { hasCapability, activeNode, activeNodeMeta } = useNodes();
if (hasCapability(capability)) return <>{children}</>;
const nodeName = activeNode?.name ?? 'this node';
const versionHint = isValidVersion(activeNodeMeta?.version)
? `${nodeName} is running v${activeNodeMeta.version}.`
: `${nodeName} does not advertise this capability.`;
return (
<LockCard
icon={Unplug}
title={`${featureName} is not available on this node`}
body={`${versionHint} Upgrade the node to use this feature.`}
/>
);
}