feat(ui): hide paid features from community-tier dashboard (#891)

* feat(ui): hide paid features from community-tier dashboard

Community installs render only the features they can use. Tier-locked
sections, lock badges, upsell cards, and "Upgrade" buttons no longer
appear anywhere except the License page in Settings, which is the
single discoverable upgrade path.

Concretely:

- PaidGate and AdmiralGate now render null for non-qualifying tiers
  instead of upsell cards.
- SectionGate (settings) hides tier-locked sections entirely.
- Settings sidebar and command palette filter out items the operator
  cannot reach.
- Configuration Status widget on the dashboard drops the Automation
  section for community and hides any locked rows in remaining
  sections.
- Fleet > Status node cards drop locked summary rows.
- Stack action menu, sidebar bulk bar, file upload / download, scan
  comparison, network topology toggle, node label picker all hide
  for community instead of showing disabled affordances or "Upgrade"
  literal text.
- Removes tierUpsell, TierLockChip, and useDismissalState (no longer
  referenced).

Backend tier guards remain authoritative; this changes UI discovery
only.

* test(e2e): assert upload control is absent in community tier

The community-clean-ui change removes the "Upgrade to unlock upload"
pill from the file explorer. Update the matching e2e assertion to
verify the upload control is not rendered, instead of waiting for a
pill that no longer exists.
This commit is contained in:
Anso
2026-05-03 01:17:02 -04:00
committed by GitHub
parent b9ada7f50b
commit 1f8ce773ff
34 changed files with 196 additions and 806 deletions
@@ -293,7 +293,7 @@ export function CloudBackupSection() {
const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)';
return (
<AdmiralGate featureName="Cloud Backup">
<AdmiralGate>
<div className="space-y-6">
<div className={PANEL_CLASS}>
<Label className="text-sm">Storage Mode</Label>
@@ -142,7 +142,7 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
};
return (
<PaidGate featureName="Stack Labels">
<PaidGate>
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex justify-end">
@@ -308,7 +308,7 @@ export function NotificationRoutingSection() {
);
return (
<AdmiralGate featureName="Notification Routing">
<AdmiralGate>
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
<div className="space-y-6">
<div className="flex justify-end">
@@ -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 (
<LockCard icon={Lock} title={title} body="Upgrade to unlock more features." />
);
}
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 <TierLockedCard tier={item.tier} />;
}
if (isItemLocked(item, visibility)) return null;
return <>{children}</>;
}
@@ -279,7 +279,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
if (!isPaid) {
return (
<div className="space-y-6">
<PaidGate featureName="Scan Policies">
<PaidGate>
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
@@ -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 (
<CommandItem value={searchValue} onSelect={onSelect}>
<span className="font-mono text-[10px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
<div className={cn('flex flex-col gap-0.5 min-w-0 flex-1', locked && 'opacity-60')}>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
</div>
{item.tier && locked ? <TierLockChip tier={item.tier} /> : null}
</CommandItem>
);
}
@@ -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
<nav className="pb-4">
{SETTINGS_GROUPS.map(group => {
const groupItems = SETTINGS_ITEMS.filter(
item => item.group === group.id && isVisible(item),
item => item.group === group.id && isReachable(item),
);
if (groupItems.length === 0) return null;
const unlockedCount = groupItems.filter(
item => !isItemLocked(item, visibility),
).length;
return (
<div key={group.id} className="mb-1 mt-3">
<div className="mb-1 flex items-center justify-between gap-2 px-2">
@@ -70,11 +69,10 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
{group.label}
</span>
<span className="font-mono text-[10px] leading-3 tabular-nums text-stat-subtitle/50">
{unlockedCount}/{groupItems.length}
{groupItems.length}
</span>
</div>
{groupItems.map(item => {
const locked = isItemLocked(item, visibility);
const isDirty = dirtyFlags?.[item.id] ?? false;
const isActive = item.id === currentSection;
@@ -89,7 +87,6 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
isActive
? 'text-stat-value'
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
locked && 'opacity-60',
)}
>
{isActive && (
@@ -109,7 +106,6 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
{isDirty && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" />
)}
{item.tier && locked && <TierLockChip tier={item.tier} showIcon={false} />}
</button>
);
})}
@@ -1,24 +0,0 @@
import { Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
export type TierLockTier = 'skipper' | 'admiral';
interface TierLockChipProps {
tier: TierLockTier;
showIcon?: boolean;
className?: string;
}
export function TierLockChip({ tier, showIcon = true, className }: TierLockChipProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/80',
className,
)}
>
{showIcon && <Lock className="h-2.5 w-2.5" strokeWidth={1.5} />}
{tier === 'admiral' ? 'Admiral' : 'Skipper'}
</span>
);
}
@@ -259,7 +259,7 @@ export function UsersSection() {
};
return (
<PaidGate featureName="User management">
<PaidGate>
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
{!showForm && (
@@ -7,8 +7,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { copyToClipboard } from '@/lib/clipboard';
import { PaidGate } from '@/components/PaidGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import {
RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2,
Plus, ChevronDown, ChevronRight, History,
@@ -154,20 +152,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
}
};
if (!isPaid) {
return (
<div className="space-y-6">
<PaidGate featureName="Webhooks">
<CapabilityGate capability="webhooks" featureName="Webhooks">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</CapabilityGate>
</PaidGate>
</div>
);
}
if (!isPaid) return null;
return (
<div className="flex flex-col gap-10">
@@ -39,5 +39,4 @@ export { SettingsSection } from './SettingsSection';
export { SettingsField, type SettingsFieldTone } from './SettingsField';
export { SettingsCallout, type SettingsCalloutTone } from './SettingsCallout';
export { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from './SettingsActions';
export { TierLockChip } from './TierLockChip';
export { useMastheadStats } from './MastheadStatsContext';