mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user