diff --git a/e2e/stack-files.spec.ts b/e2e/stack-files.spec.ts index ecaad5c7..80f41e31 100644 --- a/e2e/stack-files.spec.ts +++ b/e2e/stack-files.spec.ts @@ -196,10 +196,8 @@ test.describe('File explorer - community (read-only)', () => { await context.unroute('/api/license'); }); - test('upgrade pill is visible in the left pane', async ({ page }) => { - await expect( - page.getByRole('button', { name: /upgrade to unlock upload/i }) - ).toBeVisible({ timeout: 5_000 }); + test('upload control is absent in community tier', async ({ page }) => { + await expect(page.getByLabel('Upload file')).toHaveCount(0, { timeout: 1_000 }); }); test('can expand config/ and click config/app.conf - Save button is absent', async ({ page }) => { diff --git a/frontend/src/components/AdmiralGate.tsx b/frontend/src/components/AdmiralGate.tsx index 4fd3f86a..0218ff9a 100644 --- a/frontend/src/components/AdmiralGate.tsx +++ b/frontend/src/components/AdmiralGate.tsx @@ -1,55 +1,15 @@ -import { ShipWheel } from 'lucide-react'; +import type { ReactNode } from 'react'; import { useLicense } from '@/context/LicenseContext'; -import { useDismissalState } from '@/hooks/useDismissalState'; -import { - CompactBlurredLock, - DismissedPill, - FullUpsellCard, - type TierGateProps, -} from './tierUpsell'; - -const DISMISS_KEY = 'sencho-admiral-upgrade-prompt-dismissed'; /** - * Gate for Admiral-tier-only features. Mirrors PaidGate's state machine - * but with a stricter license predicate (requires variant === 'admiral') - * and Admiral-themed icon/copy. + * Thin wrapper that renders its children only for licensees on the + * Admiral plan. All other tiers (Community, Skipper) see nothing in + * this slot. Backend tier guards (`requireAdmiral`) remain the + * authoritative enforcement; this component only controls UI + * visibility. */ -export function AdmiralGate({ children, featureName = 'This feature', compact = false }: TierGateProps) { +export function AdmiralGate({ children }: { children: ReactNode }) { const { isPaid, license } = useLicense(); - const { dismissed, dismiss, restore } = useDismissalState(DISMISS_KEY); - - if (isPaid && license?.variant === 'admiral') return <>{children}; - - const pillText = 'Upgrade to Admiral to unlock'; - - if (compact) { - return ( - - {children} - - ); - } - - if (dismissed) { - return ; - } - - return ( - - Unlock team features like LDAP / Active Directory, audit logging, API tokens, and unlimited user accounts with a Sencho Admiral license. - For enterprise pricing or questions, contact{' '} - licensing@sencho.io. - - } - ctaIcon={ShipWheel} - ctaLabel="Get Admiral" - ctaHref="https://sencho.io/pricing" - onDismiss={dismiss} - /> - ); + const isAdmiral = isPaid && license?.variant === 'admiral'; + return isAdmiral ? <>{children} : null; } diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index 35d33921..e7a28afb 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -141,7 +141,7 @@ export function ApiTokensSection() { }; return ( - +
diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index ba2a14e5..4727d872 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -642,7 +642,7 @@ function AutoUpdateReadinessContent() { export default function AutoUpdateReadinessView() { return ( - + ); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 5d461c7f..55dcdf94 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -2522,7 +2522,7 @@ export default function EditorLayout() { ) : activeView === 'resources' ? ( ) : activeView === 'host-console' ? ( - + }> diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 17def6c5..4c8b604a 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -27,7 +27,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI import { springs } from '@/lib/motion'; import { apiFetch, fetchForNode } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; -import { PaidGate } from './PaidGate'; import { AdmiralGate } from './AdmiralGate'; import FleetSnapshots from './FleetSnapshots'; import { FleetConfiguration } from './fleet/FleetConfiguration'; @@ -1354,24 +1353,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {

)} - {/* Free tier: paid gate for advanced features */} - {!isPaid && nodes.length > 0 && ( -
- - {/* Preview of what paid tier unlocks */} -
-
-
-
-
-
-
-
-
-
- -
- )} )} @@ -1383,7 +1364,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} {isAdmiral && experimental && ( - + @@ -1397,15 +1378,13 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { {isPaid ? ( ) : ( - - } - kicker="Deployments · Blueprints" - title="Declare once. Distribute everywhere." - description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional." - plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']} - /> - + } + kicker="Deployments · Blueprints" + title="Declare once. Distribute everywhere." + description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional." + plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']} + /> )} diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index f8df4b06..e3fd9577 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -562,7 +562,7 @@ export function NodeManager() { {isPaid ? ( ) : ( - Upgrade + )} diff --git a/frontend/src/components/PaidGate.tsx b/frontend/src/components/PaidGate.tsx index 22d4af01..050be3cb 100644 --- a/frontend/src/components/PaidGate.tsx +++ b/frontend/src/components/PaidGate.tsx @@ -1,65 +1,18 @@ -import { Compass } from 'lucide-react'; +import type { ReactNode } from 'react'; import { useLicense } from '@/context/LicenseContext'; -import { useDismissalState } from '@/hooks/useDismissalState'; -import { - CompactBlurredLock, - DismissedPill, - FullUpsellCard, - type TierGateProps, -} from './tierUpsell'; - -const DISMISS_KEY = 'sencho-upgrade-prompt-dismissed'; /** - * Gate for any paid-tier feature (Skipper or Admiral). Composes the - * shared tier-upsell primitives in `./tierUpsell` according to the - * current state: + * Thin wrapper that renders its children only for licensees on a paid + * plan (Skipper or Admiral). Community-tier users see nothing in this + * slot. Backend tier guards (`requirePaid`) remain the authoritative + * enforcement; this component only controls UI visibility. * - * isPaid render children (unlocked). - * compact blurred children + small pill (inline list items). - * dismissed pill-only placeholder for 24h, click to restore. - * default full upsell card with View Plans CTA. - * - * The compact branch retains the blurred-children render because it is - * used for tiny inline UI where the visual continuity is intentional. - * The dismissed and default branches do not render children, so any - * lazy chunks behind the gate are never fetched on those paths. + * Use only when wrapping a discrete fragment that has no neighboring + * context for Community users. Where possible, prefer a parent-level + * `useLicense().isPaid` check that lifts the visibility decision out + * of the rendering tree entirely. */ -export function PaidGate({ children, featureName = 'This feature', compact = false }: TierGateProps) { +export function PaidGate({ children }: { children: ReactNode }) { const { isPaid } = useLicense(); - const { dismissed, dismiss, restore } = useDismissalState(DISMISS_KEY); - - if (isPaid) return <>{children}; - - const pillText = `Upgrade to unlock ${featureName}`; - - if (compact) { - return ( - - {children} - - ); - } - - if (dismissed) { - return ; - } - - return ( - - Unlock features like fleet management, viewer accounts, one-click Google / GitHub / Okta SSO, and more with a Skipper or Admiral license. - For enterprise pricing or questions, contact{' '} - licensing@sencho.io. - - } - ctaIcon={Compass} - ctaLabel="View Plans" - ctaHref="https://sencho.io/pricing" - onDismiss={dismiss} - /> - ); + return isPaid ? <>{children} : null; } diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx index 290e8c85..c99b97e7 100644 --- a/frontend/src/components/RegistriesSection.tsx +++ b/frontend/src/components/RegistriesSection.tsx @@ -282,7 +282,7 @@ export function RegistriesSection() { }; return ( - +
diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index d663300b..187e7ccc 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -984,27 +984,28 @@ export default function ResourcesView() { /> )}
-
- - -
+ {isPaid && ( +
+ + +
+ )} {isAdmin && networkViewMode === 'list' && ( + {isPaid && ( + + )} - ); - } + if (!isPaid) return null; const handleFile = async (file: File) => { if (file.size > MAX_BYTES) { diff --git a/frontend/src/components/files/FileViewer.tsx b/frontend/src/components/files/FileViewer.tsx index 23c900a3..27269941 100644 --- a/frontend/src/components/files/FileViewer.tsx +++ b/frontend/src/components/files/FileViewer.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, Suspense } from 'react'; import { Editor } from '@/lib/monacoLoader'; -import { AlertCircle, FileIcon, Download, Lock, Loader2, Save } from 'lucide-react'; +import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { toast } from '@/components/ui/toast-store'; @@ -72,7 +72,7 @@ function SpecialFilePanel({

{filename}

{label} · {formatBytes(size)}

- {canDownload ? ( + {canDownload && ( -
)}
); diff --git a/frontend/src/components/files/__tests__/FileViewer.test.tsx b/frontend/src/components/files/__tests__/FileViewer.test.tsx index f669d7ed..56ec4d14 100644 --- a/frontend/src/components/files/__tests__/FileViewer.test.tsx +++ b/frontend/src/components/files/__tests__/FileViewer.test.tsx @@ -160,15 +160,14 @@ describe('FileViewer', () => { expect(downloadBtn).not.toBeDisabled(); }); - it('shows disabled Download button for community tier', async () => { + it('hides Download button for community tier', async () => { licenseState.isPaid = false; mockReadFile.mockResolvedValue(binaryResult()); render(); await screen.findByText(/binary file/i); - const downloadBtn = screen.getByRole('button', { name: /download/i }); - expect(downloadBtn).toBeDisabled(); + expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument(); }); it('re-fetches when selectedPath changes', async () => { diff --git a/frontend/src/components/fleet/FleetConfiguration.tsx b/frontend/src/components/fleet/FleetConfiguration.tsx index 2a4177e6..62d0a504 100644 --- a/frontend/src/components/fleet/FleetConfiguration.tsx +++ b/frontend/src/components/fleet/FleetConfiguration.tsx @@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'; import { Bell, Zap, Shield, HardDrive, WifiOff, CheckCircle2, } from 'lucide-react'; +import { useLicense } from '@/context/LicenseContext'; import type { ConfigurationStatusPayload } from '@/components/dashboard'; interface FleetNodeConfiguration { @@ -16,34 +17,21 @@ interface FleetNodeConfiguration { configuration: ConfigurationStatusPayload | null; } -function TierChip({ tier }: { tier: string }) { - const label = tier === 'admiral' ? 'Admiral' : 'Skipper'; - return ( - - {label} - - ); -} - -function SummaryRow({ icon: Icon, label, value, locked, requiredTier }: { +function SummaryRow({ icon: Icon, label, value }: { icon: typeof Bell; label: string; value: string; - locked?: boolean; - requiredTier?: string; }) { return (
- {label} - {locked && requiredTier - ? - : {value}} + {label} + {value}
); } -function NodeCard({ node }: { node: FleetNodeConfiguration }) { +function NodeCard({ node, isPaid }: { node: FleetNodeConfiguration; isPaid: boolean }) { const isRemote = node.type === 'remote'; if (!node.configuration) { return ( @@ -90,39 +78,27 @@ function NodeCard({ node }: { node: FleetNodeConfiguration }) { value={agentCount === 0 ? 'None' : `${agentCount} active`} /> - - + {isPaid && ( + + )} + {!automation.webhooks.locked && ( + + )} {!isRemote && ( )} - - {!isRemote && ( + {!security.scanPolicies.locked && ( + + )} + {!isRemote && !backup.locked && ( + value={backup.provider === 'disabled' ? 'Disabled' : 'Enabled'} /> )} @@ -133,6 +109,7 @@ function NodeCard({ node }: { node: FleetNodeConfiguration }) { } export function FleetConfiguration() { + const { isPaid } = useLicense(); const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -193,7 +170,7 @@ export function FleetConfiguration() { return (
- {nodes.map(node => )} + {nodes.map(node => )}
); } diff --git a/frontend/src/components/settings/CloudBackupSection.tsx b/frontend/src/components/settings/CloudBackupSection.tsx index cd3f1e30..1c4efc37 100644 --- a/frontend/src/components/settings/CloudBackupSection.tsx +++ b/frontend/src/components/settings/CloudBackupSection.tsx @@ -293,7 +293,7 @@ export function CloudBackupSection() { const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)'; return ( - +
diff --git a/frontend/src/components/settings/LabelsSection.tsx b/frontend/src/components/settings/LabelsSection.tsx index ddf8a3c9..a6780cd4 100644 --- a/frontend/src/components/settings/LabelsSection.tsx +++ b/frontend/src/components/settings/LabelsSection.tsx @@ -142,7 +142,7 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) { }; return ( - +
diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index 223b33d8..c8f5d299 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -308,7 +308,7 @@ export function NotificationRoutingSection() { ); return ( - +
diff --git a/frontend/src/components/settings/SectionGate.tsx b/frontend/src/components/settings/SectionGate.tsx index c65af47f..b17a96e6 100644 --- a/frontend/src/components/settings/SectionGate.tsx +++ b/frontend/src/components/settings/SectionGate.tsx @@ -1,29 +1,22 @@ import React from 'react'; -import { Lock } from 'lucide-react'; import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { useNodes } from '@/context/NodeContext'; import { getSettingsItem, isItemVisible, isItemLocked } from './registry'; import type { VisibilityContext } from './registry'; import type { SectionId } from './types'; -import { LockCard } from '../ui/LockCard'; - -interface TierLockedCardProps { - tier: 'skipper' | 'admiral'; -} - -function TierLockedCard({ tier }: TierLockedCardProps) { - const title = tier === 'admiral' ? 'Admiral feature' : 'Skipper feature'; - return ( - - ); -} interface SectionGateProps { sectionId: SectionId; children: React.ReactNode; } +/** + * Renders the section body only if the registry says it is visible AND + * the operator has the entitlement to use it. Tier-locked sections are + * hidden entirely from operators who do not qualify. Backend tier + * guards remain the authoritative enforcement. + */ export function SectionGate({ sectionId, children }: SectionGateProps) { const { isAdmin } = useAuth(); const { isPaid, license } = useLicense(); @@ -41,15 +34,8 @@ export function SectionGate({ sectionId, children }: SectionGateProps) { const item = getSettingsItem(sectionId); - // SettingsPage routes invisible sections back to a visible default before this - // component renders, so reaching this branch means the registry shape changed - // mid-session. Render nothing rather than throw — SettingsPage's effect will - // resolve to a valid section on the next tick. if (!item || !isItemVisible(item, visibility)) return null; - - if (isItemLocked(item, visibility) && (item.tier === 'skipper' || item.tier === 'admiral')) { - return ; - } + if (isItemLocked(item, visibility)) return null; return <>{children}; } diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx index d84fbdd8..7752d4b6 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -279,7 +279,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { if (!isPaid) { return (
- +
diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 43fc4f42..be26d1af 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -37,8 +37,6 @@ import LazyBoundary from '../LazyBoundary'; import { SectionGate } from './SectionGate'; import { SettingsSidebar } from './SettingsSidebar'; import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext'; -import { TierLockChip } from './TierLockChip'; -import { cn } from '@/lib/utils'; // Paid-tier sections are loaded on demand. SectionGate short-circuits to a // TierLockedCard for Community / wrong-variant operators before reaching the @@ -116,9 +114,10 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp // node-scoped item on a remote, or admin-only item for a non-admin), fall back to // the first visible item. const safeSection: SectionId = useMemo(() => { + const reachable = (i: SettingsItemMeta) => isItemVisible(i, visibility) && !isItemLocked(i, visibility); const direct = SETTINGS_ITEMS.find(i => i.id === currentSection); - if (direct && isItemVisible(direct, visibility)) return direct.id; - const fallback = SETTINGS_ITEMS.find(i => isItemVisible(i, visibility)); + if (direct && reachable(direct)) return direct.id; + const fallback = SETTINGS_ITEMS.find(reachable); return fallback?.id ?? 'appearance'; }, [currentSection, visibility]); useEffect(() => { @@ -155,8 +154,13 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined; const nodeName = activeNode?.name ?? 'local'; + // Items reachable in this session: visible AND not tier-locked. Tier-locked + // items are hidden entirely from operators who do not qualify so the + // command palette and the sidebar never surface unreachable destinations. const visibleItems = useMemo( - () => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)), + () => SETTINGS_ITEMS.filter(item => + isItemVisible(item, visibility) && !isItemLocked(item, visibility), + ), [visibility], ); @@ -282,7 +286,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp key={item.id} item={item} glyph={group.glyph} - visibility={visibility} onSelect={() => { setCommandOpen(false); onSectionChange(item.id); @@ -305,24 +308,20 @@ function scopeLabel(item: SettingsItemMeta): string { function SettingsCommandItem({ item, glyph, - visibility, onSelect, }: { item: SettingsItemMeta; glyph: string; - visibility: VisibilityContext; onSelect: () => void; }) { - const locked = isItemLocked(item, visibility); const searchValue = [item.label, item.description, ...item.keywords].join(' ').toLowerCase(); return ( {glyph} -
+
{item.label} {item.description}
- {item.tier && locked ? : null} ); } diff --git a/frontend/src/components/settings/SettingsSidebar.tsx b/frontend/src/components/settings/SettingsSidebar.tsx index 5baf0789..bb83f32c 100644 --- a/frontend/src/components/settings/SettingsSidebar.tsx +++ b/frontend/src/components/settings/SettingsSidebar.tsx @@ -6,7 +6,6 @@ import { useNodes } from '@/context/NodeContext'; import { SETTINGS_GROUPS, SETTINGS_ITEMS, isItemVisible, isItemLocked } from './registry'; import type { VisibilityContext, SettingsItemMeta } from './registry'; import type { SectionId } from './types'; -import { TierLockChip } from './TierLockChip'; import { cn } from '@/lib/utils'; interface SettingsSidebarProps { @@ -31,8 +30,12 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o isRemote, }; - function isVisible(item: SettingsItemMeta): boolean { - return isItemVisible(item, visibility); + // An item appears in the sidebar only if its registry visibility predicate + // passes AND the operator has the entitlement for it. Tier-locked items + // are hidden entirely from operators who do not qualify so the Community + // surface stays uncluttered. Backend tier guards remain authoritative. + function isReachable(item: SettingsItemMeta): boolean { + return isItemVisible(item, visibility) && !isItemLocked(item, visibility); } return ( @@ -54,15 +57,11 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o