mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
refactor(licensing): replace Pro branding with Community/Skipper/Admiral tiers (#375)
Eliminate all references to "Pro" across backend, frontend, and docs. Internal tier value renamed from 'pro' to 'paid'; user-facing text now uses the thematic tier names (Community, Skipper, Admiral). - Rename LicenseTier 'pro' to 'paid' in backend and frontend types - Rename requirePro guard to requirePaid, error code PRO_REQUIRED to PAID_REQUIRED - Rename ProGate.tsx to PaidGate.tsx with updated copy - Fix: trial users can now see upgrade/purchase cards in Settings - Update all docs and openapi.yaml to use correct tier names
This commit is contained in:
@@ -17,10 +17,10 @@ function isDismissedFromStorage(): boolean {
|
||||
}
|
||||
|
||||
export function AdmiralGate({ children, featureName = 'This feature' }: AdmiralGateProps) {
|
||||
const { isPro, license } = useLicense();
|
||||
const { isPaid, license } = useLicense();
|
||||
const [dismissed, setDismissed] = useState(isDismissedFromStorage);
|
||||
|
||||
if (isPro && license?.variant === 'team') return <>{children}</>;
|
||||
if (isPaid && license?.variant === 'team') return <>{children}</>;
|
||||
|
||||
if (dismissed) {
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { RefreshCw, Plus, Pencil, Trash2, History, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { ProGate } from '@/components/ProGate';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
interface ScheduledTask {
|
||||
@@ -590,8 +590,8 @@ function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePo
|
||||
|
||||
export default function AutoUpdatePoliciesView({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) {
|
||||
return (
|
||||
<ProGate featureName="Auto-Update Policies">
|
||||
<PaidGate featureName="Auto-Update Policies">
|
||||
<AutoUpdatePoliciesContent filterNodeId={filterNodeId} onClearFilter={onClearFilter} />
|
||||
</ProGate>
|
||||
</PaidGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ const formatBytes = (bytes: number) => {
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPro, license } = useLicense();
|
||||
const { isPaid, license } = useLicense();
|
||||
const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes();
|
||||
// Stable ref so notification callbacks always read the latest nodes list
|
||||
// without needing nodes in their dependency arrays (which would cause loops).
|
||||
@@ -220,16 +220,16 @@ export default function EditorLayout() {
|
||||
{ value: 'templates', label: 'App Store', icon: CloudDownload },
|
||||
{ value: 'global-observability', label: 'Logs', icon: Activity },
|
||||
);
|
||||
if (isPro && isAdmin) {
|
||||
if (isPaid && isAdmin) {
|
||||
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
|
||||
}
|
||||
if (isPro && license?.variant === 'team') {
|
||||
if (isPaid && license?.variant === 'team') {
|
||||
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
|
||||
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
|
||||
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
||||
}
|
||||
return items;
|
||||
}, [isAdmin, isPro, license?.variant, can]);
|
||||
}, [isAdmin, isPaid, license?.variant, can]);
|
||||
|
||||
// Only highlight a tab if activeView matches a nav item
|
||||
const navTabValue = navItems.some(i => i.value === activeView) ? activeView : undefined;
|
||||
@@ -393,7 +393,7 @@ export default function EditorLayout() {
|
||||
};
|
||||
|
||||
const refreshLabels = async () => {
|
||||
if (!isPro) return;
|
||||
if (!isPaid) return;
|
||||
try {
|
||||
const [labelsRes, assignmentsRes] = await Promise.all([
|
||||
apiFetch('/labels'),
|
||||
@@ -866,8 +866,8 @@ export default function EditorLayout() {
|
||||
setContainers([]);
|
||||
}
|
||||
|
||||
// Load backup info (Pro only)
|
||||
if (isPro) {
|
||||
// Load backup info (Skipper+ only)
|
||||
if (isPaid) {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${filename}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
@@ -1001,7 +1001,7 @@ export default function EditorLayout() {
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
// Refresh backup info
|
||||
if (isPro) {
|
||||
if (isPaid) {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
@@ -1010,7 +1010,7 @@ export default function EditorLayout() {
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
const msg = (error as Error).message || 'Failed to deploy stack';
|
||||
toast.error(isPro ? `${msg} - automatically rolled back to previous version.` : msg);
|
||||
toast.error(isPaid ? `${msg} - automatically rolled back to previous version.` : msg);
|
||||
} finally {
|
||||
clearStackAction(stackFile);
|
||||
refreshStacks(true);
|
||||
@@ -1175,7 +1175,7 @@ export default function EditorLayout() {
|
||||
setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
if (action === 'update') fetchImageUpdates();
|
||||
if (action === 'deploy' && isPro) {
|
||||
if (action === 'deploy' && isPaid) {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||||
@@ -1184,7 +1184,7 @@ export default function EditorLayout() {
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action}:`, error);
|
||||
const msg = (error as Error).message || `Failed to ${action} stack`;
|
||||
toast.error(action === 'deploy' && isPro ? `${msg} - automatically rolled back to previous version.` : msg);
|
||||
toast.error(action === 'deploy' && isPaid ? `${msg} - automatically rolled back to previous version.` : msg);
|
||||
} finally {
|
||||
clearStackAction(stackFile);
|
||||
refreshStacks(true);
|
||||
@@ -1426,7 +1426,7 @@ export default function EditorLayout() {
|
||||
{typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+'}K
|
||||
</kbd>
|
||||
</div>
|
||||
{isPro && labels.length > 0 && (
|
||||
{isPaid && labels.length > 0 && (
|
||||
<div className="flex gap-1 px-3 py-1.5 overflow-x-auto scrollbar-none flex-none">
|
||||
{labels.map(label => (
|
||||
<ContextMenu key={label.id}>
|
||||
@@ -1500,7 +1500,7 @@ export default function EditorLayout() {
|
||||
: '--'}
|
||||
</span>
|
||||
<span className="flex-1 truncate font-mono text-[13px]">{getDisplayName(file)}</span>
|
||||
{isPro && stackLabelMap[file]?.length > 0 && (
|
||||
{isPaid && stackLabelMap[file]?.length > 0 && (
|
||||
<span className="flex items-center gap-0.5 shrink-0 ml-1">
|
||||
{stackLabelMap[file].map(l => (
|
||||
<LabelDot key={l.id} color={l.color} />
|
||||
@@ -1527,7 +1527,7 @@ export default function EditorLayout() {
|
||||
<BellRing className="h-4 w-4 mr-2" />
|
||||
Alerts
|
||||
</DropdownMenuItem>
|
||||
{isPro && (
|
||||
{isPaid && (
|
||||
<LabelAssignPopover
|
||||
stackName={file}
|
||||
allLabels={labels}
|
||||
@@ -1610,7 +1610,7 @@ export default function EditorLayout() {
|
||||
<BellRing className="h-4 w-4 mr-2" />
|
||||
Alerts
|
||||
</ContextMenuItem>
|
||||
{isPro && (
|
||||
{isPaid && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<Tag className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
@@ -1931,7 +1931,7 @@ export default function EditorLayout() {
|
||||
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'update' ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
{isPro && backupInfo.exists && (
|
||||
{isPaid && backupInfo.exists && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI
|
||||
import { springs } from '@/lib/motion';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { ProGate } from './ProGate';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { LabelDot, type Label as StackLabel } from './LabelPill';
|
||||
@@ -357,7 +357,7 @@ function ReconnectingOverlay() {
|
||||
}
|
||||
|
||||
function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record<string, StackLabel[]>; updateStatus?: NodeUpdateStatus; onUpdate?: (nodeId: number) => void; updatingNodeId?: number | null }) {
|
||||
const { isPro } = useLicense();
|
||||
const { isPaid } = useLicense();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
|
||||
const [loadingStacks, setLoadingStacks] = useState(false);
|
||||
@@ -368,7 +368,7 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
|
||||
const diskPercent = getNodeDisk(node);
|
||||
|
||||
const handleExpand = async () => {
|
||||
if (!isPro) return;
|
||||
if (!isPaid) return;
|
||||
const next = !expanded;
|
||||
setExpanded(next);
|
||||
|
||||
@@ -512,8 +512,8 @@ function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updating
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pro Expandable Stack List with Container Drill-Down */}
|
||||
{isOnline && isPro && (
|
||||
{/* Paid: Expandable Stack List with Container Drill-Down */}
|
||||
{isOnline && isPaid && (
|
||||
<div className="border-t">
|
||||
<button
|
||||
onClick={handleExpand}
|
||||
@@ -571,7 +571,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
const [fleetLabels, setFleetLabels] = useState<StackLabel[]>([]);
|
||||
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<string, StackLabel[]>>({});
|
||||
const [labelFilters, setLabelFilters] = useState<Set<number>>(new Set());
|
||||
const { isPro } = useLicense();
|
||||
const { isPaid } = useLicense();
|
||||
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
|
||||
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
@@ -606,7 +606,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
}, []);
|
||||
|
||||
const fetchLabels = useCallback(async () => {
|
||||
if (!isPro) return;
|
||||
if (!isPaid) return;
|
||||
try {
|
||||
const [labelsRes, assignmentsRes] = await Promise.all([
|
||||
apiFetch('/labels', { localOnly: true }),
|
||||
@@ -617,10 +617,10 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}, [isPro]);
|
||||
}, [isPaid]);
|
||||
|
||||
const fetchUpdateStatus = useCallback(async () => {
|
||||
if (!isPro) return;
|
||||
if (!isPaid) return;
|
||||
try {
|
||||
const res = await apiFetch('/fleet/update-status', { localOnly: true });
|
||||
if (res.ok) {
|
||||
@@ -631,7 +631,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
);
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}, [isPro]);
|
||||
}, [isPaid]);
|
||||
|
||||
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
|
||||
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
|
||||
@@ -704,12 +704,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
fetchUpdateStatus();
|
||||
}, [fetchOverview, fetchLabels, fetchUpdateStatus]);
|
||||
|
||||
// Pro: auto-refresh every 30s
|
||||
// Paid tier: auto-refresh every 30s
|
||||
useEffect(() => {
|
||||
if (!isPro) return;
|
||||
if (!isPaid) return;
|
||||
const interval = setInterval(() => fetchOverview(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isPro, fetchOverview]);
|
||||
}, [isPaid, fetchOverview]);
|
||||
|
||||
// Fast poll (5s) when any node is actively updating — uses ref to avoid interval thrashing
|
||||
const hasUpdatingRef = useRef(false);
|
||||
@@ -756,12 +756,12 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
[updateStatuses]
|
||||
);
|
||||
|
||||
// --- Filtering & Sorting (Pro) ---
|
||||
// --- Filtering & Sorting (Skipper+) ---
|
||||
|
||||
const processedNodes = useMemo(() => {
|
||||
let filtered = [...nodes];
|
||||
|
||||
// Search (Pro only, but harmless if applied - free users won't see the search bar)
|
||||
// Search (paid only, but harmless if applied - free users won't see the search bar)
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(n =>
|
||||
@@ -770,7 +770,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isPro) {
|
||||
if (isPaid) {
|
||||
// Status filter
|
||||
if (prefs.filterStatus === 'online') filtered = filtered.filter(n => n.status === 'online');
|
||||
if (prefs.filterStatus === 'offline') filtered = filtered.filter(n => n.status !== 'online');
|
||||
@@ -817,7 +817,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [nodes, searchQuery, isPro, prefs, labelFilters, fleetStackLabelMap]);
|
||||
}, [nodes, searchQuery, isPaid, prefs, labelFilters, fleetStackLabelMap]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
@@ -830,7 +830,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPro && (
|
||||
{isPaid && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -865,7 +865,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<TabsHighlightItem value="overview">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{isPro && (
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="snapshots">
|
||||
<TabsTrigger value="snapshots">
|
||||
<Camera className="w-4 h-4 mr-1.5" />Snapshots
|
||||
@@ -907,8 +907,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
{/* Fleet Content */}
|
||||
{!loading && nodes.length > 0 && (
|
||||
<>
|
||||
{/* Pro: Fleet Health Summary Cards */}
|
||||
{isPro && onlineNodes.length > 0 && (
|
||||
{/* Paid: Fleet Health Summary Cards */}
|
||||
{isPaid && onlineNodes.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
icon={Box}
|
||||
@@ -938,8 +938,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro: Search, Sort & Filter Toolbar */}
|
||||
{isPro && (
|
||||
{/* Paid: Search, Sort & Filter Toolbar */}
|
||||
{isPaid && (
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
@@ -1050,7 +1050,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
onNavigate={onNavigateToNode}
|
||||
labelMap={fleetStackLabelMap}
|
||||
updateStatus={updateStatusMap.get(node.id)}
|
||||
onUpdate={isPro ? triggerNodeUpdate : undefined}
|
||||
onUpdate={isPaid ? triggerNodeUpdate : undefined}
|
||||
updatingNodeId={updatingNodeId}
|
||||
/>
|
||||
))}
|
||||
@@ -1076,18 +1076,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pro auto-refresh indicator */}
|
||||
{isPro && (
|
||||
{/* Paid tier auto-refresh indicator */}
|
||||
{isPaid && (
|
||||
<p className="text-xs text-muted-foreground text-center mt-6">
|
||||
Auto-refreshing every 30 seconds
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Free tier: Pro gate for advanced features */}
|
||||
{!isPro && nodes.length > 0 && (
|
||||
{/* Free tier: paid gate for advanced features */}
|
||||
{!isPaid && nodes.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<ProGate featureName="Fleet Management">
|
||||
{/* Preview of what Pro unlocks */}
|
||||
<PaidGate featureName="Fleet Management">
|
||||
{/* Preview of what paid tier unlocks */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
<div className="rounded-xl border bg-card p-4 h-24" />
|
||||
@@ -1098,14 +1098,14 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<div className="h-9 rounded-md border bg-card flex-1 max-w-sm" />
|
||||
<div className="h-9 rounded-md border bg-card w-[150px]" />
|
||||
</div>
|
||||
</ProGate>
|
||||
</PaidGate>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{isPro && (
|
||||
{isPaid && (
|
||||
<TabsContent value="snapshots">
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Compass } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
|
||||
interface ProGateProps {
|
||||
interface PaidGateProps {
|
||||
children: ReactNode;
|
||||
featureName?: string;
|
||||
}
|
||||
@@ -16,11 +16,11 @@ function isDismissedFromStorage(): boolean {
|
||||
return !!dismissedAt && Date.now() - parseInt(dismissedAt, 10) < DISMISS_DURATION_MS;
|
||||
}
|
||||
|
||||
export function ProGate({ children, featureName = 'This feature' }: ProGateProps) {
|
||||
const { isPro } = useLicense();
|
||||
export function PaidGate({ children, featureName = 'This feature' }: PaidGateProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const [dismissed, setDismissed] = useState(isDismissedFromStorage);
|
||||
|
||||
if (isPro) return <>{children}</>;
|
||||
if (isPaid) return <>{children}</>;
|
||||
|
||||
if (dismissed) {
|
||||
return (
|
||||
@@ -31,7 +31,7 @@ export function ProGate({ children, featureName = 'This feature' }: ProGateProps
|
||||
<div className="absolute inset-0 flex items-start justify-center pt-8">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs">
|
||||
<Compass className="w-3 h-3" />
|
||||
Upgrade to Pro to unlock
|
||||
Upgrade to unlock more features
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,9 +44,9 @@ export function ProGate({ children, featureName = 'This feature' }: ProGateProps
|
||||
<Compass className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-2">{featureName} requires Sencho Pro</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{featureName} requires a paid license</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unlock advanced features like fleet management, viewer accounts, and more with a Sencho Pro license.
|
||||
Unlock features like fleet management, viewer accounts, and more with a Skipper or Admiral license.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
@@ -65,7 +65,7 @@ export function ProGate({ children, featureName = 'This feature' }: ProGateProps
|
||||
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
|
||||
>
|
||||
<Compass className="w-4 h-4 mr-2" />
|
||||
Get Sencho Pro
|
||||
View Plans
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,7 +20,7 @@ import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, Alert
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { ProGate } from './ProGate';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import { CapabilityGate } from './CapabilityGate';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -347,7 +347,7 @@ function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) {
|
||||
export default function ResourcesView() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const { isPro } = useLicense();
|
||||
const { isPaid } = useLicense();
|
||||
const [networkViewMode, setNetworkViewMode] = useState<'list' | 'topology'>('list');
|
||||
const [usage, setUsage] = useState<UsageData | null>(null);
|
||||
const [images, setImages] = useState<DockerImage[]>([]);
|
||||
@@ -794,7 +794,7 @@ export default function ResourcesView() {
|
||||
)}
|
||||
>
|
||||
Topology
|
||||
{!isPro && <Badge variant="outline" className="text-[9px] h-4 px-1 border-brand/30 text-brand">Pro</Badge>}
|
||||
{!isPaid && <Badge variant="outline" className="text-[9px] h-4 px-1 border-brand/30 text-brand">Skipper</Badge>}
|
||||
</button>
|
||||
</div>
|
||||
{isAdmin && networkViewMode === 'list' && (
|
||||
@@ -813,7 +813,7 @@ export default function ResourcesView() {
|
||||
|
||||
{networkViewMode === 'topology' ? (
|
||||
<div className="p-4">
|
||||
<ProGate featureName="Network Topology">
|
||||
<PaidGate featureName="Network Topology">
|
||||
<CapabilityGate capability="network-topology" featureName="Network Topology">
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
|
||||
@@ -823,7 +823,7 @@ export default function ResourcesView() {
|
||||
<NetworkTopologyView />
|
||||
</Suspense>
|
||||
</CapabilityGate>
|
||||
</ProGate>
|
||||
</PaidGate>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
|
||||
@@ -47,7 +47,7 @@ interface SettingsModalProps {
|
||||
export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModalProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const { license, isPro } = useLicense();
|
||||
const { license, isPaid } = useLicense();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(initialSection || 'account');
|
||||
|
||||
@@ -273,7 +273,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
case 'notification-routing':
|
||||
return <NotificationRoutingSection />;
|
||||
case 'webhooks':
|
||||
return <WebhooksSection isPro={isPro} />;
|
||||
return <WebhooksSection isPaid={isPaid} />;
|
||||
case 'developer':
|
||||
return (
|
||||
<DeveloperSection
|
||||
@@ -303,7 +303,7 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
}
|
||||
};
|
||||
|
||||
const isTeamPro = isPro && license?.variant === 'team';
|
||||
const isAdmiral = isPaid && license?.variant === 'team';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -332,19 +332,19 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
|
||||
{/* Users / SSO / API Tokens / Registries */}
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" locked={!isPro} />
|
||||
<NavButton section="users" icon={<Users className="w-4 h-4 mr-2" />} label="Users" locked={!isPaid} />
|
||||
)}
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" locked={!isTeamPro} />
|
||||
<NavButton section="sso" icon={<Shield className="w-4 h-4 mr-2" />} label="SSO" locked={!isAdmiral} />
|
||||
)}
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" locked={!isTeamPro} />
|
||||
<NavButton section="api-tokens" icon={<Zap className="w-4 h-4 mr-2" />} label="API Tokens" locked={!isAdmiral} />
|
||||
)}
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" locked={!isTeamPro} />
|
||||
<NavButton section="registries" icon={<Database className="w-4 h-4 mr-2" />} label="Registries" locked={!isAdmiral} />
|
||||
)}
|
||||
{!isRemote && (
|
||||
<NavButton section="labels" icon={<Tag className="w-4 h-4 mr-2" />} label="Labels" locked={!isPro} />
|
||||
<NavButton section="labels" icon={<Tag className="w-4 h-4 mr-2" />} label="Labels" locked={!isPaid} />
|
||||
)}
|
||||
|
||||
{!isRemote && isAdmin && <Separator className="my-1.5" />}
|
||||
@@ -358,10 +358,10 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal
|
||||
/>
|
||||
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
|
||||
{!isRemote && isAdmin && (
|
||||
<NavButton section="notification-routing" icon={<GitBranch className="w-4 h-4 mr-2" />} label="Routing" locked={!isTeamPro} />
|
||||
<NavButton section="notification-routing" icon={<GitBranch className="w-4 h-4 mr-2" />} label="Routing" locked={!isAdmiral} />
|
||||
)}
|
||||
{!isRemote && (
|
||||
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPro} />
|
||||
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" locked={!isPaid} />
|
||||
)}
|
||||
<NavButton
|
||||
section="developer"
|
||||
|
||||
@@ -11,15 +11,15 @@ interface TierBadgeProps {
|
||||
|
||||
const tierConfig = {
|
||||
community: { icon: Globe, label: 'Community' },
|
||||
pro: { icon: Compass, label: 'Skipper' },
|
||||
paid: { icon: Compass, label: 'Skipper' },
|
||||
team: { icon: ShipWheel, label: 'Admiral' },
|
||||
} as const;
|
||||
|
||||
function resolveTier(tier: LicenseTier, variant: LicenseVariant, status: LicenseStatus) {
|
||||
// Only show Team badge for active team licenses, not trials
|
||||
// (trials default to team variant to unlock all features)
|
||||
if (tier === 'pro' && variant === 'team' && status === 'active') return tierConfig.team;
|
||||
if (tier === 'pro') return tierConfig.pro;
|
||||
if (tier === 'paid' && variant === 'team' && status === 'active') return tierConfig.team;
|
||||
if (tier === 'paid') return tierConfig.paid;
|
||||
return tierConfig.community;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function SettingsSkeleton() {
|
||||
}
|
||||
|
||||
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote }: DeveloperSectionProps) {
|
||||
const { isPro, license } = useLicense();
|
||||
const { isPaid, license } = useLicense();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -143,7 +143,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPro && license?.variant === 'team' && (
|
||||
{isPaid && license?.variant === 'team' && (
|
||||
<div className="flex items-center justify-between gap-4 pt-4 border-t border-glass-border">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">Audit Log Retention</Label>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { ProGate } from '../ProGate';
|
||||
import { PaidGate } from '../PaidGate';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
import { LabelDot, type Label, type LabelColor } from '../LabelPill';
|
||||
|
||||
@@ -123,7 +123,7 @@ export function LabelsSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ProGate featureName="Stack Labels">
|
||||
<PaidGate featureName="Stack Labels">
|
||||
<CapabilityGate capability="labels" featureName="Stack Labels">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
@@ -236,6 +236,6 @@ export function LabelsSection() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CapabilityGate>
|
||||
</ProGate>
|
||||
</PaidGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,14 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
function getTierDisplayName(tier?: string, variant?: string | null, status?: string): string {
|
||||
if (tier === 'paid' && variant === 'team' && status === 'active') return 'Sencho Admiral';
|
||||
if (tier === 'paid') return 'Sencho Skipper';
|
||||
return 'Sencho Community';
|
||||
}
|
||||
|
||||
export function LicenseSection() {
|
||||
const { license, activate, deactivate } = useLicense();
|
||||
const { license, isPaid, activate, deactivate } = useLicense();
|
||||
const [licenseKeyInput, setLicenseKeyInput] = useState('');
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
const [isDeactivating, setIsDeactivating] = useState(false);
|
||||
@@ -36,24 +42,27 @@ export function LicenseSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const showSkipperCard = !isPaid || license?.status === 'trial';
|
||||
const showUpgradeCards = showSkipperCard || (license?.variant === 'personal' && license?.status === 'active');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium tracking-tight">License</h3>
|
||||
<p className="text-sm text-muted-foreground">Manage your Sencho Pro license.</p>
|
||||
<p className="text-sm text-muted-foreground">Manage your Sencho license.</p>
|
||||
</div>
|
||||
|
||||
{/* Current Tier Display */}
|
||||
<div className="bg-glass border border-glass-border p-4 rounded-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{license?.tier === 'pro' ? (
|
||||
{isPaid ? (
|
||||
<CheckCircle className="w-5 h-5 text-success" />
|
||||
) : (
|
||||
<Crown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium text-base">
|
||||
{license?.tier === 'pro' ? 'Sencho Pro' : 'Sencho Community'}
|
||||
{getTierDisplayName(license?.tier, license?.variant, license?.status)}
|
||||
</span>
|
||||
</div>
|
||||
<TierBadge />
|
||||
@@ -98,7 +107,7 @@ export function LicenseSection() {
|
||||
{license?.status === 'expired' && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span>Your Pro license has expired. Renew to restore Pro features.</span>
|
||||
<span>Your license has expired. Renew to restore paid features.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -110,7 +119,7 @@ export function LicenseSection() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manage Subscription (active Pro) */}
|
||||
{/* Manage Subscription (active paid license) */}
|
||||
{license?.status === 'active' && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
@@ -155,13 +164,13 @@ export function LicenseSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upgrade Cards */}
|
||||
{(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
|
||||
{/* Upgrade Cards: show for community, trial, or active Skipper users */}
|
||||
{showUpgradeCards && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">Upgrade your plan</Label>
|
||||
<div className={`grid gap-3 ${license?.tier !== 'pro' ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Skipper Card - only for Community users */}
|
||||
{license?.tier !== 'pro' && (
|
||||
<div className={`grid gap-3 ${showSkipperCard ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Skipper Card - only for Community users and trial users */}
|
||||
{showSkipperCard && (
|
||||
<div className="relative border border-glass-border rounded-lg p-4 space-y-3 bg-glass flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Compass className="w-4 h-4 text-amber-500" />
|
||||
@@ -213,7 +222,7 @@ export function LicenseSection() {
|
||||
</ul>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={license?.tier !== 'pro' ? 'outline' : 'default'}
|
||||
variant={showSkipperCard ? 'outline' : 'default'}
|
||||
className="w-full mt-auto"
|
||||
onClick={() => window.open('https://saelix.lemonsqueezy.com/checkout/buy/b049b824-176a-408d-a9d3-9365c979a61f', '_blank')}
|
||||
>
|
||||
@@ -244,7 +253,7 @@ export function LicenseSection() {
|
||||
setIsActivating(true);
|
||||
const result = await activate(licenseKeyInput.trim());
|
||||
if (result.success) {
|
||||
toast.success('License activated! Welcome to Sencho Pro.');
|
||||
toast.success('License activated successfully.');
|
||||
setLicenseKeyInput('');
|
||||
} else {
|
||||
toast.error(result.error || 'Activation failed');
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TierBadge } from '@/components/TierBadge';
|
||||
import { Book, Bug, Mail, ExternalLink, Crown } from 'lucide-react';
|
||||
|
||||
export function SupportSection() {
|
||||
const { isPro, license } = useLicense();
|
||||
const { isPaid, license } = useLicense();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -42,11 +42,11 @@ export function SupportSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pro support channels */}
|
||||
{isPro && (
|
||||
{/* Paid tier support channels */}
|
||||
{isPaid && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
Pro Support <TierBadge />
|
||||
Priority Support <TierBadge />
|
||||
</h4>
|
||||
<div className="grid gap-3">
|
||||
<a href={license?.variant === 'team' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
|
||||
@@ -71,17 +71,17 @@ export function SupportSection() {
|
||||
)}
|
||||
|
||||
{/* Upsell for Community */}
|
||||
{!isPro && (
|
||||
{!isPaid && (
|
||||
<div className="rounded-lg border border-glass-border p-4 bg-muted/30">
|
||||
<div className="flex items-start gap-3">
|
||||
<Crown className="w-5 h-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Need faster support?</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Upgrade to Pro for direct email support and priority issue handling.
|
||||
Upgrade to Skipper or Admiral for direct email support and priority issue handling.
|
||||
</p>
|
||||
<Button size="sm" className="mt-3" onClick={() => window.open('https://sencho.io/#pricing', '_blank')}>
|
||||
Upgrade to Pro
|
||||
View Plans
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useAuth, type UserRole } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { ProGate } from '@/components/ProGate';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react';
|
||||
|
||||
@@ -35,7 +35,7 @@ interface RoleAssignmentItem {
|
||||
|
||||
export function UsersSection() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { isPro, license } = useLicense();
|
||||
const { isPaid, license } = useLicense();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -230,7 +230,7 @@ export function UsersSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ProGate featureName="User management">
|
||||
<PaidGate featureName="User management">
|
||||
<CapabilityGate capability="users" featureName="User Management">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
@@ -267,7 +267,7 @@ export function UsersSection() {
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
{isPro && license?.variant === 'team' && (
|
||||
{isPaid && license?.variant === 'team' && (
|
||||
<>
|
||||
<SelectItem value="deployer">Deployer</SelectItem>
|
||||
<SelectItem value="node-admin">Node Admin</SelectItem>
|
||||
@@ -306,7 +306,7 @@ export function UsersSection() {
|
||||
</div>
|
||||
|
||||
{/* Scoped Permissions (Admiral, editing only) */}
|
||||
{editingUser && isPro && license?.variant === 'team' && (
|
||||
{editingUser && isPaid && license?.variant === 'team' && (
|
||||
<div className="border border-glass-border rounded-lg p-4 space-y-3 mt-4">
|
||||
<h4 className="text-sm font-medium">Scoped Permissions</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -455,6 +455,6 @@ export function UsersSection() {
|
||||
)}
|
||||
</div>
|
||||
</CapabilityGate>
|
||||
</ProGate>
|
||||
</PaidGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { ProGate } from '@/components/ProGate';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
import { TierBadge } from '@/components/TierBadge';
|
||||
import {
|
||||
@@ -38,7 +38,7 @@ interface WebhookExecution {
|
||||
executed_at: number;
|
||||
}
|
||||
|
||||
export function WebhooksSection({ isPro }: { isPro: boolean }) {
|
||||
export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -134,21 +134,21 @@ export function WebhooksSection({ isPro }: { isPro: boolean }) {
|
||||
toast.success(`${label} copied to clipboard.`);
|
||||
};
|
||||
|
||||
if (!isPro) {
|
||||
if (!isPaid) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">Webhooks <TierBadge /></h3>
|
||||
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
|
||||
</div>
|
||||
<ProGate featureName="Webhooks">
|
||||
<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>
|
||||
</ProGate>
|
||||
</PaidGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user