mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
feat(pricing): collapse to two tiers (#1309)
* feat(pricing): collapse to two tiers (Community + Admiral) Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral) to two: a generous free Community tier and a single paid Admiral tier. The Skipper tier is removed. Now free in Community: auto-heal, auto-update, scheduled operations, webhooks, notification routing, Fleet Actions and bulk operations, SSO preset providers (Google / GitHub / Okta), unlimited users with admin and viewer roles, and deploy safety (atomic deploys, auto-rollback, and one-click rollback). Admiral (paid) is focused on running and governing a fleet: blueprints, Fleet Secrets, deploy enforcement, vulnerability report export, audit log, host console, private registries, mesh networking, node cordon, managed cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles (deployer, node-admin, auditor) with per-resource scoped assignments. Internally the license variant distinction is removed so tier is binary (community / paid). License validation still verifies the Lemon Squeezy store and product before granting paid status. Docs and the contributor guide are updated to the two-tier model. * docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording The licensing docs page kept the old Admiral pricing plus a Founder Lifetime column and an Enterprise paragraph after the two-tier collapse. Update it to $12/month or $99/year, drop the lifetime and Enterprise content, and link to the pricing page for current pricing. Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title, and three test comments. Historical CHANGELOG entries and the retired-Skipper license-guard test are intentionally left as-is. * docs: align licensing and SSO pages with the two-tier model Correct the SSO overview so the Google, GitHub, and Okta presets read as available on every tier, matching the provider table; only LDAP and Active Directory require Sencho Admiral. Remove the lifetime-plan references from the licensing, settings, and troubleshooting pages so they reflect subscription-only Admiral pricing. * fix(rbac): omit scoped permissions from /me on the Community tier Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case. * docs: use custom-pricing wording on the contact page The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists.
This commit is contained in:
@@ -1,15 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
|
||||
/**
|
||||
* 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 }: { children: ReactNode }) {
|
||||
const { isPaid, license } = useLicense();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
return isAdmiral ? <>{children}</> : null;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { ScheduledTask } from '@/types/scheduling';
|
||||
|
||||
@@ -657,9 +656,5 @@ function AutoUpdateReadinessContent() {
|
||||
}
|
||||
|
||||
export default function AutoUpdateReadinessView() {
|
||||
return (
|
||||
<PaidGate>
|
||||
<AutoUpdateReadinessContent />
|
||||
</PaidGate>
|
||||
);
|
||||
return <AutoUpdateReadinessContent />;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { StructuredLogRow } from '@/components/log-rendering/StructuredLogRow';
|
||||
import TerminalComponent from '@/components/Terminal';
|
||||
import { useDeployFeedback, VERB_LABELS } from '@/context/DeployFeedbackContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
|
||||
const AUTO_CLOSE_SECONDS = 4;
|
||||
|
||||
@@ -33,7 +32,6 @@ function formatElapsed(seconds: number): string {
|
||||
|
||||
export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) {
|
||||
const { panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
@@ -216,16 +214,6 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Atomic-deploy notice for Community: deploys without auto-rollback. */}
|
||||
{!isPaid && (action === 'deploy' || action === 'update') && (
|
||||
<div
|
||||
className="px-4 py-1.5 text-xs text-muted-foreground bg-muted/40 border-b border-glass-border shrink-0"
|
||||
role="note"
|
||||
>
|
||||
Auto-rollback on failure is a Skipper feature.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
|
||||
import type { SenchoOpenLogsDetail } from '@/lib/events';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { StackSidebar } from '@/components/sidebar/StackSidebar';
|
||||
@@ -42,7 +41,6 @@ import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
const { status: trivy } = useTrivyStatus();
|
||||
const { runWithLog, panelState } = useDeployFeedback();
|
||||
|
||||
@@ -142,8 +140,6 @@ export default function EditorLayout() {
|
||||
navItems,
|
||||
} = navState;
|
||||
|
||||
const isAdmiral = license?.variant === 'admiral';
|
||||
|
||||
const {
|
||||
notifications,
|
||||
tickerConnected,
|
||||
@@ -169,7 +165,6 @@ export default function EditorLayout() {
|
||||
activeNode,
|
||||
setActiveNode,
|
||||
nodes,
|
||||
isPaid,
|
||||
runWithLog,
|
||||
diffPreviewEnabled,
|
||||
});
|
||||
@@ -183,8 +178,6 @@ export default function EditorLayout() {
|
||||
overlayState,
|
||||
stackActions,
|
||||
activeNode,
|
||||
isPaid,
|
||||
isAdmiral,
|
||||
isAdmin,
|
||||
can,
|
||||
});
|
||||
@@ -374,7 +367,6 @@ export default function EditorLayout() {
|
||||
list={{
|
||||
files: chipFilteredFiles,
|
||||
isLoading,
|
||||
isPaid,
|
||||
selectedFile,
|
||||
searchQuery,
|
||||
stackLabelMap,
|
||||
@@ -402,7 +394,6 @@ export default function EditorLayout() {
|
||||
onActivityAction={handleActivityAction}
|
||||
bulkMode={bulkMode}
|
||||
selectedFiles={selectedFiles}
|
||||
isPaid={isPaid}
|
||||
onToggleBulkMode={toggleBulkMode}
|
||||
onToggleSelect={toggleSelect}
|
||||
onClearSelection={clearSelection}
|
||||
@@ -493,7 +484,6 @@ export default function EditorLayout() {
|
||||
stackMisconfigScanning={stackMisconfigScanning}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
isPaid={isPaid}
|
||||
trivy={trivy}
|
||||
activeNode={activeNode}
|
||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
||||
|
||||
@@ -188,7 +188,6 @@ export interface EditorViewProps {
|
||||
// Permissions / tier / context
|
||||
can: ReturnType<typeof useAuth>['can'];
|
||||
isAdmin: boolean;
|
||||
isPaid: boolean;
|
||||
trivy: { available: boolean };
|
||||
activeNode: Node | null;
|
||||
|
||||
@@ -255,7 +254,6 @@ export function EditorView({
|
||||
stackMisconfigScanning,
|
||||
can,
|
||||
isAdmin,
|
||||
isPaid,
|
||||
trivy,
|
||||
activeNode,
|
||||
copiedDigestTimerRef,
|
||||
@@ -404,7 +402,7 @@ export function EditorView({
|
||||
{(() => {
|
||||
const canDeploy = can('stack:deploy', 'stack', stackName);
|
||||
const canDelete = can('stack:delete', 'stack', stackName);
|
||||
const canRollback = canDeploy && isPaid && backupInfo.exists;
|
||||
const canRollback = canDeploy && backupInfo.exists;
|
||||
const canScan = trivy.available && isAdmin;
|
||||
const hasOverflowExtras = canRollback || canScan;
|
||||
const hasOverflow = hasOverflowExtras || canDelete;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Suspense, lazy, type ReactNode } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { AdmiralGate } from '../AdmiralGate';
|
||||
import { PaidGate } from '../PaidGate';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
import { HubOnlyGate } from '../HubOnlyGate';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
@@ -15,7 +15,7 @@ import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
|
||||
import type { ActiveView } from './hooks/useViewNavigationState';
|
||||
|
||||
// Paid-tier views and the security-history overlay are loaded on demand.
|
||||
// Their internal PaidGate / AdmiralGate / CapabilityGate wrappers render
|
||||
// Their internal PaidGate / CapabilityGate wrappers render
|
||||
// the upsell or capability-missing card with blurred children rather than
|
||||
// short-circuiting, so a tier-locked or capability-missing operator
|
||||
// opening one of these tabs still triggers the chunk fetch to render the
|
||||
@@ -127,13 +127,13 @@ export function ViewRouter({
|
||||
// view another way from mounting a console that the server will 403.
|
||||
if (!can('system:console')) return null;
|
||||
return (
|
||||
<AdmiralGate>
|
||||
<PaidGate>
|
||||
<CapabilityGate capability="host-console" featureName="Host Console">
|
||||
<LazyView>
|
||||
<HostConsole stackName={selectedFile} onClose={onHostConsoleClose} />
|
||||
</LazyView>
|
||||
</CapabilityGate>
|
||||
</AdmiralGate>
|
||||
</PaidGate>
|
||||
);
|
||||
}
|
||||
// Fall-through: when activeView === 'editor' but selectedFile is
|
||||
|
||||
@@ -23,18 +23,16 @@ function mockCommunityUser() {
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: false,
|
||||
license: null,
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
}
|
||||
|
||||
function mockAdmiralAdmin() {
|
||||
function mockPaidAdmin() {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
isAdmin: true,
|
||||
can: (p: string) => p === 'system:audit',
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: true,
|
||||
license: { variant: 'admiral' } as ReturnType<typeof LicenseContext.useLicense>['license'],
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
}
|
||||
|
||||
@@ -45,18 +43,6 @@ function mockCommunityAdmin() {
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: false,
|
||||
license: null,
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
}
|
||||
|
||||
function mockSkipperAdmin() {
|
||||
vi.mocked(AuthContext.useAuth).mockReturnValue({
|
||||
isAdmin: true,
|
||||
can: () => false,
|
||||
} as unknown as ReturnType<typeof AuthContext.useAuth>);
|
||||
vi.mocked(LicenseContext.useLicense).mockReturnValue({
|
||||
isPaid: true,
|
||||
license: { variant: 'skipper' } as ReturnType<typeof LicenseContext.useLicense>['license'],
|
||||
} as unknown as ReturnType<typeof LicenseContext.useLicense>);
|
||||
}
|
||||
|
||||
@@ -231,6 +217,16 @@ describe('useViewNavigationState', () => {
|
||||
expect(result.current.navItems.map(i => i.value)).toContain('global-observability');
|
||||
});
|
||||
|
||||
it('shows Auto-Update and Schedules for a community admin (now free) but hides paid Console and Audit', () => {
|
||||
mockCommunityAdmin();
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
const values = result.current.navItems.map(i => i.value);
|
||||
expect(values).toContain('auto-updates');
|
||||
expect(values).toContain('scheduled-ops');
|
||||
expect(values).not.toContain('host-console');
|
||||
expect(values).not.toContain('audit-log');
|
||||
});
|
||||
|
||||
it('redirects a non-admin off the Logs view when reached via a deep-link event', () => {
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
// Community (non-admin) is the beforeEach default.
|
||||
@@ -244,10 +240,10 @@ describe('useViewNavigationState', () => {
|
||||
expect(onNavigateToDashboard).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── navItems: admiral admin ────────────────────────────────────────────────
|
||||
// ── navItems: paid admin ───────────────────────────────────────────────────
|
||||
|
||||
it('navItems for admiral paid admin contains all items', () => {
|
||||
mockAdmiralAdmin();
|
||||
it('navItems for a paid admin contains all items', () => {
|
||||
mockPaidAdmin();
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
const values = result.current.navItems.map(i => i.value);
|
||||
expect(values).toContain('auto-updates');
|
||||
@@ -256,22 +252,10 @@ describe('useViewNavigationState', () => {
|
||||
expect(values).toContain('scheduled-ops');
|
||||
});
|
||||
|
||||
// ── navItems: skipper admin ────────────────────────────────────────────────
|
||||
|
||||
it('navItems for skipper paid admin contains schedules and auto-updates but not admiral items', () => {
|
||||
mockSkipperAdmin();
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
const values = result.current.navItems.map(i => i.value);
|
||||
expect(values).toContain('auto-updates');
|
||||
expect(values).toContain('scheduled-ops');
|
||||
expect(values).not.toContain('host-console');
|
||||
expect(values).not.toContain('audit-log');
|
||||
});
|
||||
|
||||
// ── navItems: hub-only gating on remote node ───────────────────────────────
|
||||
|
||||
it('hides hub-only views from the nav strip when active node is remote', () => {
|
||||
mockAdmiralAdmin();
|
||||
mockPaidAdmin();
|
||||
mockActiveNode('remote');
|
||||
const { result } = renderHook(() => useViewNavigationState());
|
||||
const values = result.current.navItems.map(i => i.value);
|
||||
@@ -288,7 +272,7 @@ describe('useViewNavigationState', () => {
|
||||
});
|
||||
|
||||
it('shows hub-only views again when active node switches back to local', () => {
|
||||
mockAdmiralAdmin();
|
||||
mockPaidAdmin();
|
||||
mockActiveNode('remote');
|
||||
const { result, rerender } = renderHook(() => useViewNavigationState());
|
||||
expect(result.current.navItems.map(i => i.value)).not.toContain('fleet');
|
||||
@@ -305,7 +289,7 @@ describe('useViewNavigationState', () => {
|
||||
|
||||
it('auto-redirects to dashboard when active view is hub-only and node becomes remote', () => {
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
mockAdmiralAdmin();
|
||||
mockPaidAdmin();
|
||||
mockActiveNode('local');
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useViewNavigationState({ onNavigateToDashboard }),
|
||||
@@ -329,7 +313,7 @@ describe('useViewNavigationState', () => {
|
||||
|
||||
it('does not redirect when a non-hub-only view is active and node becomes remote', () => {
|
||||
const onNavigateToDashboard = vi.fn();
|
||||
mockAdmiralAdmin();
|
||||
mockPaidAdmin();
|
||||
mockActiveNode('local');
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useViewNavigationState({ onNavigateToDashboard }),
|
||||
|
||||
@@ -19,8 +19,6 @@ interface UseSidebarContextMenuOptions {
|
||||
overlayState: OverlayState;
|
||||
stackActions: StackActionsHook;
|
||||
activeNode: Node | null | undefined;
|
||||
isPaid: boolean;
|
||||
isAdmiral: boolean;
|
||||
isAdmin: boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
|
||||
}
|
||||
@@ -31,8 +29,6 @@ export function useSidebarContextMenu({
|
||||
overlayState,
|
||||
stackActions,
|
||||
activeNode,
|
||||
isPaid,
|
||||
isAdmiral,
|
||||
isAdmin,
|
||||
can,
|
||||
}: UseSidebarContextMenuOptions) {
|
||||
@@ -42,8 +38,6 @@ export function useSidebarContextMenu({
|
||||
stackStatus: (stackListState.stackStatuses[file] ?? 'unknown') as 'running' | 'exited' | 'unknown',
|
||||
hasPort: Boolean(stackListState.stackPorts[file]),
|
||||
isBusy: stackListState.isStackBusy(file),
|
||||
isPaid,
|
||||
isAdmiral,
|
||||
isAdmin,
|
||||
canDelete: can('stack:delete', 'stack', sName),
|
||||
canEditLabels: can('stack:edit', 'stack', sName),
|
||||
@@ -130,7 +124,7 @@ export function useSidebarContextMenu({
|
||||
// deps would force a rebuild on every parent render and defeat the memo.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
stackListState.stackStatuses, stackListState.stackPorts, isPaid, isAdmiral, isAdmin,
|
||||
stackListState.stackStatuses, stackListState.stackPorts, isAdmin,
|
||||
stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap,
|
||||
stackListState.pin, stackListState.unpin,
|
||||
]);
|
||||
|
||||
@@ -98,7 +98,6 @@ function setup(over: { editorState?: Partial<EditorState>; overlay?: Partial<Ove
|
||||
activeNode: { id: 1, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
|
||||
setActiveNode: vi.fn(),
|
||||
nodes: [],
|
||||
isPaid: false,
|
||||
runWithLog,
|
||||
diffPreviewEnabled: false,
|
||||
}),
|
||||
@@ -144,7 +143,6 @@ describe('useStackActions.saveFile', () => {
|
||||
activeNode: { id: 1, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
|
||||
setActiveNode: vi.fn(),
|
||||
nodes: [],
|
||||
isPaid: false,
|
||||
runWithLog,
|
||||
diffPreviewEnabled: false,
|
||||
}),
|
||||
|
||||
@@ -58,7 +58,6 @@ interface UseStackActionsOptions {
|
||||
activeNode: Node | null | undefined;
|
||||
setActiveNode: (node: Node) => void;
|
||||
nodes: Node[];
|
||||
isPaid: boolean;
|
||||
runWithLog: (
|
||||
params: { stackName: string; action: ActionVerb },
|
||||
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>,
|
||||
@@ -128,7 +127,6 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
activeNode,
|
||||
setActiveNode,
|
||||
nodes,
|
||||
isPaid,
|
||||
runWithLog,
|
||||
diffPreviewEnabled,
|
||||
} = options;
|
||||
@@ -295,7 +293,6 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
};
|
||||
|
||||
const loadBackupState = async (filename: string, signal?: AbortSignal) => {
|
||||
if (!isPaid) return;
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${filename}/backup`, { signal });
|
||||
if (signal?.aborted) return;
|
||||
@@ -575,13 +572,11 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
const conts = await containersRes.json();
|
||||
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
if (isPaid) {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
@@ -591,7 +586,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
const deployError = error as StackActionError;
|
||||
const errorMessage = deployError.message || 'Failed to deploy stack';
|
||||
toast.error(
|
||||
isPaid && deployError.rolledBack === true
|
||||
deployError.rolledBack === true
|
||||
? `${errorMessage} - automatically rolled back to previous version.`
|
||||
: errorMessage,
|
||||
);
|
||||
@@ -963,7 +958,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
if (action === 'update') stackListState.fetchImageUpdates();
|
||||
if (action === 'deploy' && isPaid) {
|
||||
if (action === 'deploy') {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
@@ -976,7 +971,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
const actionError = error as StackActionError;
|
||||
const msg = actionError.message || `Failed to ${action} stack`;
|
||||
toast.error(
|
||||
action === 'deploy' && isPaid && actionError.rolledBack === true
|
||||
action === 'deploy' && actionError.rolledBack === true
|
||||
? `${msg} - automatically rolled back to previous version.`
|
||||
: msg,
|
||||
);
|
||||
|
||||
@@ -52,7 +52,7 @@ interface UseViewNavigationStateOptions {
|
||||
export function useViewNavigationState(options?: UseViewNavigationStateOptions) {
|
||||
const { onNavigateToDashboard } = options ?? {};
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
@@ -108,18 +108,18 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
|
||||
// The aggregated Logs feed crosses every managed stack, so it is an
|
||||
// admin-only operator view (the backend gates the same routes on admin).
|
||||
if (isAdmin) items.push({ value: 'global-observability', label: 'Logs', icon: Activity });
|
||||
if (isPaid && isAdmin) {
|
||||
if (isAdmin) {
|
||||
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
|
||||
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
||||
}
|
||||
if (isPaid && license?.variant === 'admiral') {
|
||||
if (isPaid) {
|
||||
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
|
||||
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
|
||||
}
|
||||
return isRemote
|
||||
? items.filter(i => !HUB_ONLY_VIEWS.has(i.value))
|
||||
: items;
|
||||
}, [isAdmin, isPaid, license?.variant, can, isRemote]);
|
||||
}, [isAdmin, isPaid, can, isRemote]);
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect off a view the active context can't reach: a hub-only view while
|
||||
|
||||
@@ -71,11 +71,10 @@ const PAGE_SIZE = 10;
|
||||
|
||||
export default function FleetSnapshots() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { license, isPaid } = useLicense();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
// Cloud-upload affordance is reachable when the saved provider is custom
|
||||
// (every tier) or sencho on an Admiral license. A downgraded admin whose
|
||||
// (every tier) or sencho on a paid license. A downgraded admin whose
|
||||
// saved provider is still 'sencho' sees no upload button — they cannot
|
||||
// call POST /cloud-backup/upload/:id because gateForCurrentProvider would
|
||||
// 403 anyway, so the UI must not advertise an action that is gated away.
|
||||
@@ -133,11 +132,11 @@ export default function FleetSnapshots() {
|
||||
const res = await apiFetch('/cloud-backup/config', { localOnly: true });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as { provider: 'disabled' | 'sencho' | 'custom' };
|
||||
setCloudEnabled(data.provider === 'custom' || (data.provider === 'sencho' && isAdmiral));
|
||||
setCloudEnabled(data.provider === 'custom' || (data.provider === 'sencho' && isPaid));
|
||||
} catch {
|
||||
// best-effort; cloud affordances stay hidden on failure
|
||||
}
|
||||
}, [isAdmiral]);
|
||||
}, [isPaid]);
|
||||
|
||||
const fetchCloudSnapshots = useCallback(async () => {
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI
|
||||
import { springs } from '@/lib/motion';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { AdmiralGate } from './AdmiralGate';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { FleetConfiguration } from './fleet/FleetConfiguration';
|
||||
import { RoutingTab } from './fleet/RoutingTab';
|
||||
@@ -34,9 +34,8 @@ interface FleetViewProps {
|
||||
}
|
||||
|
||||
export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
const { isPaid, license } = useLicense();
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
|
||||
const { prefs, updatePrefs } = useFleetPreferences();
|
||||
const updateStatus = useFleetUpdateStatus();
|
||||
@@ -98,14 +97,14 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="routing">
|
||||
<TabsTrigger value="routing">
|
||||
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Routing
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
{isPaid && (
|
||||
<TabsHighlightItem value="federation">
|
||||
<TabsTrigger value="federation">
|
||||
<Network className="w-4 h-4 mr-1.5" />Federation
|
||||
@@ -207,18 +206,18 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<DeploymentsTab />
|
||||
</TabsContent>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
{isPaid && (
|
||||
<TabsContent value="routing">
|
||||
<AdmiralGate>
|
||||
<PaidGate>
|
||||
<RoutingTab canManage={isAdmin} />
|
||||
</AdmiralGate>
|
||||
</PaidGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
{isAdmiral && (
|
||||
{isPaid && (
|
||||
<TabsContent value="federation">
|
||||
<AdmiralGate>
|
||||
<PaidGate>
|
||||
<FederationTab canManage={isAdmin} />
|
||||
</AdmiralGate>
|
||||
</PaidGate>
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="actions">
|
||||
|
||||
@@ -67,17 +67,16 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u
|
||||
const [cordonReason, setCordonReason] = useState('');
|
||||
const [cordonSubmitting, setCordonSubmitting] = useState(false);
|
||||
|
||||
const { isPaid, license } = useLicense();
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { nodes: registryNodes } = useNodes();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const registryNode = registryNodes.find(n => n.id === node.id);
|
||||
const canEdit = Boolean(isAdmin && onEdit && registryNode);
|
||||
const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default);
|
||||
// Cordon is Admiral-tier AND requires node:manage, matching the backend guard
|
||||
// (requirePermission('node:manage','node',id) + requireAdmiral). Gating on tier
|
||||
// Cordon is a paid feature AND requires node:manage, matching the backend guard
|
||||
// (requirePermission('node:manage','node',id) + requirePaid). Gating on tier
|
||||
// alone would surface the control to deployer/viewer/auditor users whose calls 403.
|
||||
const canCordon = isAdmiral && can('node:manage', 'node', String(node.id));
|
||||
const canCordon = isPaid && can('node:manage', 'node', String(node.id));
|
||||
const showMenu = canEdit || canDelete || canCordon;
|
||||
|
||||
const isOnline = node.status === 'online';
|
||||
|
||||
@@ -36,7 +36,7 @@ function baseProps(node: FleetNode) {
|
||||
beforeEach(() => {
|
||||
useNodesMock.mockReturnValue({ nodes: [] });
|
||||
useAuthMock.mockReturnValue({ isAdmin: true, can: vi.fn(() => true) });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false, license: null });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -55,24 +55,24 @@ describe('NodeCard', () => {
|
||||
expect(screen.queryByText('Running')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the actions menu for a non-admiral user', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'skipper' } });
|
||||
it('hides the actions menu for a free-tier user', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the actions menu (cordon entry point) for an admiral admin', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
|
||||
it('exposes the actions menu (cordon entry point) for a paid admin', () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
// With no edit/delete affordances wired, the menu renders iff cordon is
|
||||
// allowed: isAdmiral && can('node:manage'). The admin's can() returns true.
|
||||
// allowed: isPaid && can('node:manage'). The admin's can() returns true.
|
||||
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the cordon control for a node-admin via the node:manage permission', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
@@ -80,18 +80,18 @@ describe('NodeCard', () => {
|
||||
expect(can).toHaveBeenCalledWith('node:manage', 'node', '2');
|
||||
});
|
||||
|
||||
it('hides the cordon control from an admiral user lacking node:manage', () => {
|
||||
it('hides the cordon control from a paid user lacking node:manage', () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
// Admiral tier alone must not surface cordon to a deployer/viewer/auditor.
|
||||
// The paid tier alone must not surface cordon to a deployer/viewer/auditor.
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Uncordon when the node is already cordoned', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<NodeCard {...baseProps({ ...onlineNode(), cordoned: true, cordoned_reason: 'patching' })} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { ReactNode } from 'react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Thin wrapper that renders its children only for licensees on the paid
|
||||
* plan. Community-tier users see nothing in this slot. Backend tier
|
||||
* guards (`requirePaid`) remain the authoritative enforcement; this
|
||||
* component only controls UI visibility.
|
||||
*
|
||||
* Use only when wrapping a discrete fragment that has no neighboring
|
||||
* context for Community users. Where possible, prefer a parent-level
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Combobox } from '@/components/ui/combobox';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { AdmiralGate } from './AdmiralGate';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import { CapabilityGate } from './CapabilityGate';
|
||||
import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock, Zap } from 'lucide-react';
|
||||
import { SettingsPrimaryButton } from './settings/SettingsActions';
|
||||
@@ -283,7 +283,7 @@ export function RegistriesSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdmiralGate>
|
||||
<PaidGate>
|
||||
<CapabilityGate capability="registries" featureName="Private Registries">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
@@ -474,6 +474,6 @@ export function RegistriesSection() {
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
</CapabilityGate>
|
||||
</AdmiralGate>
|
||||
</PaidGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { CapabilityGate } from './CapabilityGate';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import { AdmiralGate } from './AdmiralGate';
|
||||
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { SettingsPrimaryButton } from './settings/SettingsActions';
|
||||
import { useMastheadStats } from './settings/MastheadStatsContext';
|
||||
@@ -46,7 +45,7 @@ interface SSOProviderConfig {
|
||||
oidcEmailClaim?: string;
|
||||
}
|
||||
|
||||
// Ordered by tier: Custom OIDC (Community) → preset OIDC (Skipper) → LDAP/AD (Admiral).
|
||||
// Ordered by tier: free OIDC (Custom + presets) first, then LDAP/AD (paid).
|
||||
// The ordering reinforces the free → paid progression in the UI.
|
||||
const PROVIDERS = [
|
||||
{ id: 'oidc_custom', label: 'Custom OIDC', type: 'oidc' as const },
|
||||
@@ -397,7 +396,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
|
||||
}
|
||||
|
||||
// Mirrors the backend tier split in ssoConfig.ts requireTierForProvider: Custom OIDC
|
||||
// is free, preset OIDC (Google/GitHub/Okta) requires Skipper+, LDAP requires Admiral.
|
||||
// and preset OIDC (Google/GitHub/Okta) are free, LDAP/AD requires the paid plan.
|
||||
function ProviderCardWithGate(props: {
|
||||
providerId: string;
|
||||
type: 'ldap' | 'oidc';
|
||||
@@ -406,11 +405,10 @@ function ProviderCardWithGate(props: {
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const card = <ProviderCard {...props} />;
|
||||
if (props.providerId === 'oidc_custom') return card;
|
||||
if (props.providerId === 'ldap') {
|
||||
return <AdmiralGate>{card}</AdmiralGate>;
|
||||
return <PaidGate>{card}</PaidGate>;
|
||||
}
|
||||
return <PaidGate>{card}</PaidGate>;
|
||||
return card;
|
||||
}
|
||||
|
||||
export function SSOSection() {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
|
||||
interface StackAlert {
|
||||
id?: number;
|
||||
@@ -129,22 +128,16 @@ function actionLabel(action: AutoHealHistoryEntry['action']): string {
|
||||
}
|
||||
|
||||
export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'alerts' }: StackAlertSheetProps) {
|
||||
const { isPaid } = useLicense();
|
||||
// Per Sencho convention: paid features hide their trigger entirely. Community users
|
||||
// never see the Auto-heal tab, and a stray initialTab='auto-heal' falls back to alerts.
|
||||
const effectiveInitialTab: MonitorTab = !isPaid && initialTab === 'auto-heal' ? 'alerts' : initialTab;
|
||||
const [activeTab, setActiveTab] = useState<MonitorTab>(effectiveInitialTab);
|
||||
const [activeTab, setActiveTab] = useState<MonitorTab>(initialTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab(effectiveInitialTab);
|
||||
}, [open, effectiveInitialTab, stackName]);
|
||||
if (open) setActiveTab(initialTab);
|
||||
}, [open, initialTab, stackName]);
|
||||
|
||||
const tabs = isPaid
|
||||
? [
|
||||
{ id: 'alerts', label: 'Alerts' },
|
||||
{ id: 'auto-heal', label: 'Auto-heal' },
|
||||
]
|
||||
: [{ id: 'alerts', label: 'Alerts' }];
|
||||
const tabs = [
|
||||
{ id: 'alerts', label: 'Alerts' },
|
||||
{ id: 'auto-heal', label: 'Auto-heal' },
|
||||
];
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
@@ -159,7 +152,7 @@ export function StackAlertSheet({ open, onOpenChange, stackName, initialTab = 'a
|
||||
size="md"
|
||||
>
|
||||
{activeTab === 'alerts' && <AlertsTab stackName={stackName} />}
|
||||
{activeTab === 'auto-heal' && isPaid && <AutoHealTab stackName={stackName} open={open} />}
|
||||
{activeTab === 'auto-heal' && <AutoHealTab stackName={stackName} open={open} />}
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
import { Compass, Globe, ShipWheel } from 'lucide-react';
|
||||
import { Globe, ShipWheel } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useLicense, type LicenseTier, type LicenseVariant, type LicenseStatus } from '@/context/LicenseContext';
|
||||
import { useLicense, type LicenseTier } from '@/context/LicenseContext';
|
||||
|
||||
interface TierBadgeProps {
|
||||
tier?: LicenseTier;
|
||||
variant?: LicenseVariant;
|
||||
status?: LicenseStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const tierConfig = {
|
||||
community: { icon: Globe, label: 'Community' },
|
||||
skipper: { icon: Compass, label: 'Skipper' },
|
||||
admiral: { icon: ShipWheel, label: 'Admiral' },
|
||||
paid: { icon: ShipWheel, label: 'Admiral' },
|
||||
} as const;
|
||||
|
||||
function resolveTier(tier: LicenseTier, variant: LicenseVariant, status: LicenseStatus) {
|
||||
// Only show Admiral badge for active admiral licenses (trials default to skipper)
|
||||
if (tier === 'paid' && variant === 'admiral' && status === 'active') return tierConfig.admiral;
|
||||
if (tier === 'paid') return tierConfig.skipper;
|
||||
return tierConfig.community;
|
||||
}
|
||||
|
||||
export function TierBadge({ tier, variant, status, className }: TierBadgeProps) {
|
||||
export function TierBadge({ tier, className }: TierBadgeProps) {
|
||||
const { license } = useLicense();
|
||||
const resolvedTier = tier ?? license?.tier ?? 'community';
|
||||
const resolvedVariant = variant !== undefined ? variant : license?.variant ?? null;
|
||||
const resolvedStatus = status ?? license?.status ?? 'community';
|
||||
const { icon: Icon, label } = resolveTier(resolvedTier, resolvedVariant, resolvedStatus);
|
||||
const { icon: Icon, label } = resolvedTier === 'paid' ? tierConfig.paid : tierConfig.community;
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className={`gap-1 text-[10px] font-semibold uppercase px-1.5 py-0 ${className || ''}`}>
|
||||
|
||||
@@ -33,9 +33,6 @@ vi.mock('../CapabilityGate', () => ({
|
||||
vi.mock('../PaidGate', () => ({
|
||||
PaidGate: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock('../AdmiralGate', () => ({
|
||||
AdmiralGate: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock('../settings/MastheadStatsContext', () => ({
|
||||
useMastheadStats: () => undefined,
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react';
|
||||
import { formatCount } from '@/lib/utils';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useConfigurationStatus } from './useConfigurationStatus';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
|
||||
@@ -83,7 +82,6 @@ function SkeletonRow() {
|
||||
|
||||
export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps = {}) {
|
||||
const { status, loading } = useConfigurationStatus();
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
const open = (section: SectionId) => () => onOpenSection?.(section);
|
||||
|
||||
@@ -156,34 +154,30 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPaid && (
|
||||
<>
|
||||
<SectionHeader icon={Zap} label="Automation" />
|
||||
<Row
|
||||
label="Auto-heal policies"
|
||||
value={automation.autoHeal.total === 0 ? 'None' : `${automation.autoHeal.enabled} / ${automation.autoHeal.total} active`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Auto-update schedules"
|
||||
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total} active`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
{!automation.webhooks.locked && (
|
||||
<Row
|
||||
label="Webhooks"
|
||||
value={formatCount(automation.webhooks.enabled, 'active')}
|
||||
onClick={open('webhooks')}
|
||||
/>
|
||||
)}
|
||||
{!automation.scheduledTasks.locked && (
|
||||
<Row
|
||||
label="Scheduled tasks"
|
||||
value={formatCount(automation.scheduledTasks.enabled, 'active')}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<SectionHeader icon={Zap} label="Automation" />
|
||||
<Row
|
||||
label="Auto-heal policies"
|
||||
value={automation.autoHeal.total === 0 ? 'None' : `${automation.autoHeal.enabled} / ${automation.autoHeal.total} active`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Auto-update schedules"
|
||||
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total} active`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
{!automation.webhooks.locked && (
|
||||
<Row
|
||||
label="Webhooks"
|
||||
value={formatCount(automation.webhooks.enabled, 'active')}
|
||||
onClick={open('webhooks')}
|
||||
/>
|
||||
)}
|
||||
{!automation.scheduledTasks.locked && (
|
||||
<Row
|
||||
label="Scheduled tasks"
|
||||
value={formatCount(automation.scheduledTasks.enabled, 'active')}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SectionHeader icon={Shield} label="Security" />
|
||||
|
||||
@@ -6,18 +6,12 @@ vi.mock('../useConfigurationStatus', () => ({
|
||||
useConfigurationStatus: () => useConfigurationStatusMock(),
|
||||
}));
|
||||
|
||||
const useLicenseMock = vi.fn();
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => useLicenseMock(),
|
||||
}));
|
||||
|
||||
import { ConfigurationStatus } from '../ConfigurationStatus';
|
||||
import type { ConfigurationStatus as ConfigurationStatusPayload } from '../useConfigurationStatus';
|
||||
|
||||
function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): ConfigurationStatusPayload {
|
||||
return {
|
||||
tier: 'community',
|
||||
variant: null,
|
||||
notifications: {
|
||||
agents: {
|
||||
discord: { configured: false, enabled: false },
|
||||
@@ -25,19 +19,19 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): Confi
|
||||
webhook: { configured: false, enabled: false },
|
||||
},
|
||||
alertRules: 0,
|
||||
routingRules: { count: 0, enabledCount: 0, locked: true, requiredTier: 'skipper' },
|
||||
routingRules: { count: 0, enabledCount: 0, locked: true },
|
||||
},
|
||||
automation: {
|
||||
autoHeal: { total: 0, enabled: 0 },
|
||||
autoUpdate: { enabled: 0, total: 0 },
|
||||
scheduledTasks: { total: 0, enabled: 0, locked: true, requiredTier: 'admiral' },
|
||||
webhooks: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' },
|
||||
scheduledTasks: { total: 0, enabled: 0, locked: true },
|
||||
webhooks: { total: 0, enabled: 0, locked: true },
|
||||
},
|
||||
security: {
|
||||
mfaEnabled: null,
|
||||
ssoEnabled: false,
|
||||
ssoProvider: null,
|
||||
scanPolicies: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' },
|
||||
scanPolicies: { total: 0, enabled: 0, locked: true },
|
||||
},
|
||||
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false },
|
||||
backup: { provider: 'disabled', autoUpload: false, locked: false },
|
||||
@@ -47,39 +41,34 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): Confi
|
||||
|
||||
beforeEach(() => {
|
||||
useConfigurationStatusMock.mockReset();
|
||||
useLicenseMock.mockReset();
|
||||
});
|
||||
|
||||
describe('ConfigurationStatus tier parity', () => {
|
||||
describe('ConfigurationStatus row visibility', () => {
|
||||
it('renders a skeleton while loading', () => {
|
||||
useConfigurationStatusMock.mockReturnValue({ status: null, loading: true });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<ConfigurationStatus />);
|
||||
expect(screen.getByText('Configuration Status')).toBeDefined();
|
||||
// Skeleton renders 8 placeholder rows; assert the load-error message
|
||||
// is NOT shown.
|
||||
// Skeleton renders placeholder rows; assert the load-error message is NOT shown.
|
||||
expect(screen.queryByText(/Unable to load configuration/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders an error state when the payload is null and not loading', () => {
|
||||
useConfigurationStatusMock.mockReturnValue({ status: null, loading: false });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<ConfigurationStatus />);
|
||||
expect(screen.getByText(/Unable to load configuration/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('hides the Automation section, routing rules, vulnerability scanning, and webhooks for Community', () => {
|
||||
it('always shows the Automation section and its free rows, hiding only the per-row locked entries', () => {
|
||||
useConfigurationStatusMock.mockReturnValue({ status: makePayload(), loading: false });
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
render(<ConfigurationStatus />);
|
||||
|
||||
// Notifications section header always renders.
|
||||
expect(screen.getByText('Notifications')).toBeDefined();
|
||||
// Locked rows should be absent for Community.
|
||||
// Automation moved to free: the section and its auto-heal / auto-update
|
||||
// rows render for every tier.
|
||||
expect(screen.getByText('Automation')).toBeDefined();
|
||||
expect(screen.getByText('Auto-heal policies')).toBeDefined();
|
||||
expect(screen.getByText('Auto-update schedules')).toBeDefined();
|
||||
// Rows whose payload reports locked stay hidden.
|
||||
expect(screen.queryByText('Notification routing')).toBeNull();
|
||||
expect(screen.queryByText('Automation')).toBeNull();
|
||||
expect(screen.queryByText('Auto-heal policies')).toBeNull();
|
||||
expect(screen.queryByText('Auto-update schedules')).toBeNull();
|
||||
expect(screen.queryByText('Webhooks')).toBeNull();
|
||||
expect(screen.queryByText('Scheduled tasks')).toBeNull();
|
||||
expect(screen.queryByText('Vulnerability scanning')).toBeNull();
|
||||
@@ -87,11 +76,10 @@ describe('ConfigurationStatus tier parity', () => {
|
||||
expect(screen.getByText('Cloud Backup')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows Automation rows and Webhooks for Skipper but keeps Scheduled tasks hidden', () => {
|
||||
it('shows every row when the payload reports nothing locked', () => {
|
||||
useConfigurationStatusMock.mockReturnValue({
|
||||
status: makePayload({
|
||||
tier: 'paid',
|
||||
variant: 'skipper',
|
||||
notifications: {
|
||||
agents: {
|
||||
discord: { configured: false, enabled: false },
|
||||
@@ -99,69 +87,28 @@ describe('ConfigurationStatus tier parity', () => {
|
||||
webhook: { configured: false, enabled: false },
|
||||
},
|
||||
alertRules: 2,
|
||||
routingRules: { count: 1, enabledCount: 1, locked: false, requiredTier: 'skipper' },
|
||||
routingRules: { count: 1, enabledCount: 1, locked: false },
|
||||
},
|
||||
automation: {
|
||||
autoHeal: { total: 3, enabled: 2 },
|
||||
autoUpdate: { enabled: 4, total: 5 },
|
||||
scheduledTasks: { total: 0, enabled: 0, locked: true, requiredTier: 'admiral' },
|
||||
webhooks: { total: 1, enabled: 1, locked: false, requiredTier: 'skipper' },
|
||||
},
|
||||
security: {
|
||||
mfaEnabled: true,
|
||||
ssoEnabled: false,
|
||||
ssoProvider: null,
|
||||
scanPolicies: { total: 2, enabled: 2, locked: false, requiredTier: 'skipper' },
|
||||
},
|
||||
}),
|
||||
loading: false,
|
||||
});
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<ConfigurationStatus />);
|
||||
|
||||
expect(screen.getByText('Automation')).toBeDefined();
|
||||
expect(screen.getByText('Auto-heal policies')).toBeDefined();
|
||||
expect(screen.getByText('Auto-update schedules')).toBeDefined();
|
||||
expect(screen.getByText('Webhooks')).toBeDefined();
|
||||
expect(screen.getByText('Notification routing')).toBeDefined();
|
||||
expect(screen.getByText('Vulnerability scanning')).toBeDefined();
|
||||
// Scheduled tasks is Admiral-only; the response.locked flag controls
|
||||
// visibility independently of the outer isPaid block.
|
||||
expect(screen.queryByText('Scheduled tasks')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows every gated row for Admiral', () => {
|
||||
useConfigurationStatusMock.mockReturnValue({
|
||||
status: makePayload({
|
||||
tier: 'paid',
|
||||
variant: 'admiral',
|
||||
notifications: {
|
||||
agents: {
|
||||
discord: { configured: false, enabled: false },
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
},
|
||||
alertRules: 0,
|
||||
routingRules: { count: 0, enabledCount: 0, locked: false, requiredTier: 'skipper' },
|
||||
},
|
||||
automation: {
|
||||
autoHeal: { total: 0, enabled: 0 },
|
||||
autoUpdate: { enabled: 0, total: 0 },
|
||||
scheduledTasks: { total: 1, enabled: 1, locked: false, requiredTier: 'admiral' },
|
||||
webhooks: { total: 0, enabled: 0, locked: false, requiredTier: 'skipper' },
|
||||
scheduledTasks: { total: 1, enabled: 1, locked: false },
|
||||
webhooks: { total: 1, enabled: 1, locked: false },
|
||||
},
|
||||
security: {
|
||||
mfaEnabled: true,
|
||||
ssoEnabled: true,
|
||||
ssoProvider: 'oidc_google',
|
||||
scanPolicies: { total: 0, enabled: 0, locked: false, requiredTier: 'skipper' },
|
||||
scanPolicies: { total: 2, enabled: 2, locked: false },
|
||||
},
|
||||
}),
|
||||
loading: false,
|
||||
});
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
render(<ConfigurationStatus />);
|
||||
|
||||
expect(screen.getByText('Automation')).toBeDefined();
|
||||
expect(screen.getByText('Auto-heal policies')).toBeDefined();
|
||||
expect(screen.getByText('Auto-update schedules')).toBeDefined();
|
||||
expect(screen.getByText('Notification routing')).toBeDefined();
|
||||
expect(screen.getByText('Webhooks')).toBeDefined();
|
||||
expect(screen.getByText('Scheduled tasks')).toBeDefined();
|
||||
|
||||
@@ -38,19 +38,18 @@ beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
apiFetchMock.mockImplementation(() => Promise.resolve(okJson({
|
||||
tier: 'community',
|
||||
variant: null,
|
||||
notifications: { agents: {}, alertRules: 0, routingRules: { count: 0, enabledCount: 0, locked: true, requiredTier: 'skipper' } },
|
||||
notifications: { agents: {}, alertRules: 0, routingRules: { count: 0, enabledCount: 0, locked: true } },
|
||||
automation: {
|
||||
autoHeal: { total: 0, enabled: 0 },
|
||||
autoUpdate: { enabled: 0, total: 0 },
|
||||
scheduledTasks: { total: 0, enabled: 0, locked: true, requiredTier: 'admiral' },
|
||||
webhooks: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' },
|
||||
scheduledTasks: { total: 0, enabled: 0, locked: true },
|
||||
webhooks: { total: 0, enabled: 0, locked: true },
|
||||
},
|
||||
security: {
|
||||
mfaEnabled: null,
|
||||
ssoEnabled: false,
|
||||
ssoProvider: null,
|
||||
scanPolicies: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' },
|
||||
scanPolicies: { total: 0, enabled: 0, locked: true },
|
||||
},
|
||||
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false },
|
||||
backup: { provider: 'disabled', autoUpload: false, locked: false },
|
||||
|
||||
@@ -7,9 +7,9 @@ vi.mock('@/lib/api', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
|
||||
}));
|
||||
|
||||
const useAuthMock = vi.fn();
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => useAuthMock(),
|
||||
const useLicenseMock = vi.fn();
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => useLicenseMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils', async () => {
|
||||
@@ -38,7 +38,7 @@ function statusJson(status: number, payload: unknown = {}): Response {
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
useAuthMock.mockReset();
|
||||
useLicenseMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -46,8 +46,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('useMeshDataPlane', () => {
|
||||
it('does not fetch /mesh/status when the session is non-Admiral', async () => {
|
||||
useAuthMock.mockReturnValue({ permissions: { isAdmiral: false } });
|
||||
it('does not fetch /mesh/status when the session is on the free tier', async () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: false });
|
||||
const { result } = renderHook(() => useMeshDataPlane());
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
|
||||
@@ -56,8 +56,8 @@ describe('useMeshDataPlane', () => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches once on mount and surfaces the localDataPlane payload for Admiral', async () => {
|
||||
useAuthMock.mockReturnValue({ permissions: { isAdmiral: true } });
|
||||
it('fetches once on mount and surfaces the localDataPlane payload for a paid tier', async () => {
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
apiFetchMock.mockResolvedValue(okJson({
|
||||
localDataPlane: { ok: true, reason: null, lastChecked: 1000 },
|
||||
}));
|
||||
@@ -71,7 +71,7 @@ describe('useMeshDataPlane', () => {
|
||||
});
|
||||
|
||||
it('keeps status null on a 403 response without raising an error', async () => {
|
||||
useAuthMock.mockReturnValue({ permissions: { isAdmiral: true } });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
apiFetchMock.mockResolvedValue(statusJson(403, { error: 'forbidden' }));
|
||||
|
||||
const { result } = renderHook(() => useMeshDataPlane());
|
||||
@@ -82,7 +82,7 @@ describe('useMeshDataPlane', () => {
|
||||
});
|
||||
|
||||
it('falls back to null when the response omits localDataPlane', async () => {
|
||||
useAuthMock.mockReturnValue({ permissions: { isAdmiral: true } });
|
||||
useLicenseMock.mockReturnValue({ isPaid: true });
|
||||
apiFetchMock.mockResolvedValue(okJson({ nodes: [] }));
|
||||
|
||||
const { result } = renderHook(() => useMeshDataPlane());
|
||||
|
||||
@@ -14,23 +14,22 @@ interface AgentStatus {
|
||||
|
||||
export interface ConfigurationStatus {
|
||||
tier: 'community' | 'paid';
|
||||
variant: 'skipper' | 'admiral' | null;
|
||||
notifications: {
|
||||
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus };
|
||||
alertRules: number;
|
||||
routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'skipper' };
|
||||
routingRules: { count: number; enabledCount: number; locked: boolean };
|
||||
};
|
||||
automation: {
|
||||
autoHeal: { total: number; enabled: number };
|
||||
autoUpdate: { enabled: number; total: number };
|
||||
scheduledTasks: { total: number; enabled: number; locked: boolean; requiredTier: 'admiral' };
|
||||
webhooks: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' };
|
||||
scheduledTasks: { total: number; enabled: number; locked: boolean };
|
||||
webhooks: { total: number; enabled: number; locked: boolean };
|
||||
};
|
||||
security: {
|
||||
mfaEnabled: boolean | null;
|
||||
ssoEnabled: boolean;
|
||||
ssoProvider: string | null;
|
||||
scanPolicies: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' };
|
||||
scanPolicies: { total: number; enabled: number; locked: boolean };
|
||||
};
|
||||
thresholds: {
|
||||
cpuLimit: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
import type { MeshDataPlaneStatus } from '@/types/mesh';
|
||||
|
||||
@@ -12,15 +12,13 @@ export interface MeshDataPlaneResult {
|
||||
/**
|
||||
* Poll `/mesh/status` for the local data-plane health so dashboard surfaces
|
||||
* can flag a down mesh without opening the Routing tab. The endpoint is
|
||||
* Admiral-gated, so the hook short-circuits on non-Admiral tiers (no
|
||||
* request fired, no banner rendered). On the rare 403 from an Admiral
|
||||
* tier (token race during downgrade) we leave `status` at null. 30 s
|
||||
* cadence matches `useFleetHeartbeat` so the dashboard refresh feel is
|
||||
* consistent.
|
||||
* paid-gated, so the hook short-circuits on the free tier (no request
|
||||
* fired, no banner rendered). On the rare 403 from a paid tier (token
|
||||
* race during downgrade) we leave `status` at null. 30 s cadence matches
|
||||
* `useFleetHeartbeat` so the dashboard refresh feel is consistent.
|
||||
*/
|
||||
export function useMeshDataPlane(): MeshDataPlaneResult {
|
||||
const { permissions } = useAuth();
|
||||
const isAdmiral = permissions?.isAdmiral ?? false;
|
||||
const { isPaid } = useLicense();
|
||||
const [status, setStatus] = useState<MeshDataPlaneStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -43,14 +41,14 @@ export function useMeshDataPlane(): MeshDataPlaneResult {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmiral) {
|
||||
if (!isPaid) {
|
||||
setStatus(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
void fetchStatus();
|
||||
return visibilityInterval(() => { void fetchStatus(); }, 30_000);
|
||||
}, [isAdmiral, fetchStatus]);
|
||||
}, [isPaid, fetchStatus]);
|
||||
|
||||
return { status, loading };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import {
|
||||
Bell, Zap, Shield, HardDrive, WifiOff, CheckCircle2, RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus';
|
||||
import { STICKY_CONTROL_IDENTITY_MISMATCH, type FleetSyncStatus } from '@/lib/fleetSyncApi';
|
||||
import type { ConfigurationStatusPayload } from '@/components/dashboard';
|
||||
@@ -92,9 +91,8 @@ function PolicySyncRow({ state }: { state: PolicySyncState }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ node, isPaid, policySyncState }: {
|
||||
function NodeCard({ node, policySyncState }: {
|
||||
node: FleetNodeConfiguration;
|
||||
isPaid: boolean;
|
||||
policySyncState: PolicySyncState | null;
|
||||
}) {
|
||||
const isRemote = node.type === 'remote';
|
||||
@@ -143,12 +141,10 @@ function NodeCard({ node, isPaid, policySyncState }: {
|
||||
value={agentCount === 0 ? 'None' : `${agentCount} active`} />
|
||||
<SummaryRow icon={Bell} label="Alert rules"
|
||||
value={formatCount(notifications.alertRules, 'rule')} />
|
||||
{isPaid && (
|
||||
<SummaryRow icon={Zap} label="Auto-heal"
|
||||
value={automation.autoHeal.total === 0
|
||||
? 'None'
|
||||
: `${automation.autoHeal.enabled}/${automation.autoHeal.total}`} />
|
||||
)}
|
||||
<SummaryRow icon={Zap} label="Auto-heal"
|
||||
value={automation.autoHeal.total === 0
|
||||
? 'None'
|
||||
: `${automation.autoHeal.enabled}/${automation.autoHeal.total}`} />
|
||||
{!automation.webhooks.locked && (
|
||||
<SummaryRow icon={Zap} label="Webhooks"
|
||||
value={formatCount(automation.webhooks.enabled, 'active')} />
|
||||
@@ -175,7 +171,6 @@ function NodeCard({ node, isPaid, policySyncState }: {
|
||||
}
|
||||
|
||||
export function FleetConfiguration() {
|
||||
const { isPaid } = useLicense();
|
||||
const { statuses: syncStatuses } = useFleetSyncStatus();
|
||||
const [nodes, setNodes] = useState<FleetNodeConfiguration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -256,7 +251,6 @@ export function FleetConfiguration() {
|
||||
<NodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
isPaid={isPaid}
|
||||
policySyncState={syncStateByNode.get(node.id) ?? null}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -68,9 +68,8 @@ const PANEL_CLASS = 'rounded-lg border border-card-border border-t-card-border-t
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function CloudBackupSection() {
|
||||
const { license, isPaid } = useLicense();
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
const providerOptions = isAdmiral
|
||||
const { isPaid } = useLicense();
|
||||
const providerOptions = isPaid
|
||||
? [BASE_PROVIDER_OPTIONS[0], SENCHO_PROVIDER_OPTION, BASE_PROVIDER_OPTIONS[1]]
|
||||
: BASE_PROVIDER_OPTIONS;
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -312,7 +311,7 @@ export function CloudBackupSection() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAdmiral && provider === 'sencho' && !senchoProvisioned && (
|
||||
{isPaid && provider === 'sencho' && !senchoProvisioned && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
@@ -328,7 +327,7 @@ export function CloudBackupSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmiral && provider === 'sencho' && senchoProvisioned && (
|
||||
{isPaid && provider === 'sencho' && senchoProvisioned && (
|
||||
<div className={PANEL_CLASS}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
@@ -41,10 +42,10 @@ const DEFAULT_DEVELOPER: DeveloperFields = {
|
||||
};
|
||||
|
||||
export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
const { isAdmin, permissions } = useAuth();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const readOnly = !isAdmin;
|
||||
const isAdmiral = permissions?.isAdmiral ?? false;
|
||||
const [settings, setSettings] = useState<DeveloperFields>({ ...DEFAULT_DEVELOPER });
|
||||
const serverSettingsRef = useRef<DeveloperFields>({ ...DEFAULT_DEVELOPER });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -202,7 +203,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) {
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
{isAdmiral && (
|
||||
{isPaid && (
|
||||
<SettingsField
|
||||
label="Audit log"
|
||||
helper="How long to keep audit trail entries."
|
||||
|
||||
@@ -16,17 +16,14 @@ import { useMastheadStats } from './MastheadStatsContext';
|
||||
|
||||
const PRICING_URL = 'https://sencho.io/pricing';
|
||||
|
||||
function getTierDisplayName(tier?: string, variant?: string | null, status?: string): string {
|
||||
if (tier === 'paid' && variant === 'admiral' && status === 'active') return 'Sencho Admiral';
|
||||
if (tier === 'paid' && variant === 'admiral' && status === 'trial') return 'Sencho Admiral (Trial)';
|
||||
if (tier === 'paid') return 'Sencho Skipper';
|
||||
function getTierDisplayName(tier?: string, status?: string): string {
|
||||
if (tier === 'paid' && status === 'trial') return 'Sencho Admiral (Trial)';
|
||||
if (tier === 'paid') return 'Sencho Admiral';
|
||||
return 'Sencho Community';
|
||||
}
|
||||
|
||||
function getTierMastheadValue(tier?: string, variant?: string | null): string {
|
||||
if (tier === 'paid' && variant === 'admiral') return 'admiral';
|
||||
if (tier === 'paid') return 'skipper';
|
||||
return 'community';
|
||||
function getTierMastheadValue(tier?: string): string {
|
||||
return tier === 'paid' ? 'admiral' : 'community';
|
||||
}
|
||||
|
||||
export function LicenseSection() {
|
||||
@@ -69,7 +66,7 @@ export function LicenseSection() {
|
||||
useMastheadStats([
|
||||
{
|
||||
label: 'PLAN',
|
||||
value: getTierMastheadValue(license?.tier, license?.variant),
|
||||
value: getTierMastheadValue(license?.tier),
|
||||
tone: isPaid ? 'value' : 'subtitle',
|
||||
},
|
||||
...(license?.status === 'trial' && license.trialDaysRemaining !== null
|
||||
@@ -93,7 +90,7 @@ export function LicenseSection() {
|
||||
<div className="flex flex-col gap-10">
|
||||
<SettingsSection title="Plan">
|
||||
<SettingsField
|
||||
label={getTierDisplayName(license?.tier, license?.variant, license?.status)}
|
||||
label={getTierDisplayName(license?.tier, license?.status)}
|
||||
helper={
|
||||
license?.status === 'expired'
|
||||
? 'Your license has expired. Renew to restore paid features.'
|
||||
|
||||
@@ -18,17 +18,15 @@ interface SectionGateProps {
|
||||
* guards remain the authoritative enforcement.
|
||||
*/
|
||||
export function SectionGate({ sectionId, children }: SectionGateProps) {
|
||||
const { isAdmin, permissions } = useAuth();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
|
||||
const isAdmiral = permissions?.isAdmiral ?? false;
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
const visibility: VisibilityContext = {
|
||||
isAdmin,
|
||||
isPaid,
|
||||
isAdmiral,
|
||||
isRemote,
|
||||
};
|
||||
|
||||
|
||||
@@ -100,14 +100,13 @@ export function SettingsPage(props: SettingsPageProps) {
|
||||
}
|
||||
|
||||
function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProps) {
|
||||
const { isAdmin, permissions } = useAuth();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const isAdmiral = permissions?.isAdmiral ?? false;
|
||||
const visibility: VisibilityContext = useMemo(
|
||||
() => ({ isRemote, isAdmin, isPaid, isAdmiral }),
|
||||
[isRemote, isAdmin, isPaid, isAdmiral],
|
||||
() => ({ isRemote, isAdmin, isPaid }),
|
||||
[isRemote, isAdmin, isPaid],
|
||||
);
|
||||
|
||||
// Resolve the rendered section: must be a registry id and must be visible to the
|
||||
@@ -195,7 +194,7 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
|
||||
case 'system': return <SystemSection onDirtyChange={(d) => handleDirtyChange('system', d)} />;
|
||||
case 'notifications': return <NotificationsSection />;
|
||||
case 'notification-routing': return <NotificationRoutingSection />;
|
||||
case 'webhooks': return <WebhooksSection isPaid={isPaid} />;
|
||||
case 'webhooks': return <WebhooksSection />;
|
||||
case 'security': return <SecuritySection isPaid={isPaid} />;
|
||||
case 'cloud-backup': return <CloudBackupSection />;
|
||||
case 'developer': return <DeveloperSection onDirtyChange={(d) => handleDirtyChange('developer', d)} />;
|
||||
|
||||
@@ -16,17 +16,15 @@ interface SettingsSidebarProps {
|
||||
}
|
||||
|
||||
export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, onOpenPalette }: SettingsSidebarProps) {
|
||||
const { isAdmin, permissions } = useAuth();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid } = useLicense();
|
||||
const { activeNode } = useNodes();
|
||||
|
||||
const isAdmiral = permissions?.isAdmiral ?? false;
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
|
||||
const visibility: VisibilityContext = {
|
||||
isAdmin,
|
||||
isPaid,
|
||||
isAdmiral,
|
||||
isRemote,
|
||||
};
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ function ResourceLink({ icon, title, blurb, href, external = true }: ResourceLin
|
||||
}
|
||||
|
||||
export function SupportSection() {
|
||||
const { isPaid, license } = useLicense();
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
@@ -63,13 +63,9 @@ export function SupportSection() {
|
||||
<div className="pt-3 grid gap-3">
|
||||
<ResourceLink
|
||||
icon={<Mail className="w-4 h-4" />}
|
||||
title={license?.variant === 'admiral' ? 'Priority email support' : 'Email support'}
|
||||
blurb={
|
||||
license?.variant === 'admiral'
|
||||
? 'Direct support with responses within 24 hours'
|
||||
: 'Reach our support team directly'
|
||||
}
|
||||
href={license?.variant === 'admiral' ? 'mailto:support@sencho.io' : 'mailto:licensing@sencho.io'}
|
||||
title="Priority email support"
|
||||
blurb="Direct support with responses within 24 hours"
|
||||
href="mailto:support@sencho.io"
|
||||
external={false}
|
||||
/>
|
||||
</div>
|
||||
@@ -80,7 +76,7 @@ export function SupportSection() {
|
||||
<SettingsCallout
|
||||
icon={<Crown className="h-4 w-4" />}
|
||||
title="Need faster support?"
|
||||
subtitle="Skipper and Admiral tiers include direct email support and priority issue handling."
|
||||
subtitle="Admiral includes direct email support and priority issue handling."
|
||||
action={
|
||||
<SettingsPrimaryButton
|
||||
size="sm"
|
||||
|
||||
@@ -10,7 +10,6 @@ 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 { PaidGate } from '@/components/PaidGate';
|
||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
import { RefreshCw, Trash2, Plus, Pencil, ShieldOff } from 'lucide-react';
|
||||
import { SettingsCallout } from './SettingsCallout';
|
||||
@@ -37,7 +36,7 @@ interface RoleAssignmentItem {
|
||||
|
||||
export function UsersSection() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
const { isPaid } = useLicense();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -260,8 +259,7 @@ export function UsersSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PaidGate>
|
||||
<CapabilityGate capability="users" featureName="User Management">
|
||||
<CapabilityGate capability="users" featureName="User Management">
|
||||
<div className="space-y-6">
|
||||
{!showForm && (
|
||||
<div className="flex justify-end">
|
||||
@@ -290,7 +288,7 @@ export function UsersSection() {
|
||||
options={[
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
...(isPaid && license?.variant === 'admiral' ? [
|
||||
...(isPaid ? [
|
||||
{ value: 'deployer', label: 'Deployer' },
|
||||
{ value: 'node-admin', label: 'Node Admin' },
|
||||
{ value: 'auditor', label: 'Auditor' },
|
||||
@@ -336,8 +334,8 @@ export function UsersSection() {
|
||||
</SettingsPrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* Scoped Permissions (Admiral, editing only) */}
|
||||
{editingUser && isPaid && license?.variant === 'admiral' && (
|
||||
{/* Scoped Permissions (paid, editing only) */}
|
||||
{editingUser && isPaid && (
|
||||
<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">
|
||||
@@ -522,7 +520,6 @@ export function UsersSection() {
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
</CapabilityGate>
|
||||
</PaidGate>
|
||||
</CapabilityGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ interface WebhookExecution {
|
||||
executed_at: number;
|
||||
}
|
||||
|
||||
export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
export function WebhooksSection() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode, nodes } = useNodes();
|
||||
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
|
||||
@@ -158,8 +158,6 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isPaid) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
{isAdmin && (
|
||||
|
||||
@@ -16,7 +16,7 @@ export const SETTINGS_GROUPS: readonly SettingsGroupMeta[] = [
|
||||
{ id: 'advanced', label: 'Advanced', glyph: '\u25C7' },
|
||||
];
|
||||
|
||||
export type TierGate = 'skipper' | 'admiral' | null;
|
||||
export type TierGate = 'paid' | null;
|
||||
export type Scope = 'global' | 'node';
|
||||
|
||||
export interface SettingsItemMeta {
|
||||
@@ -67,7 +67,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
label: 'Users',
|
||||
description: 'Operators, role assignments, and access scopes.',
|
||||
keywords: ['operators', 'team', 'rbac', 'roles', 'permissions'],
|
||||
tier: 'skipper',
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
@@ -109,7 +109,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
label: 'Registries',
|
||||
description: 'Private Docker registries and pull credentials.',
|
||||
keywords: ['docker', 'ghcr', 'ecr', 'private', 'pull', 'auth'],
|
||||
tier: 'admiral',
|
||||
tier: 'paid',
|
||||
scope: 'global',
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
@@ -150,7 +150,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
label: 'Routing',
|
||||
description: 'Rules that steer alerts to the right channel based on severity or label.',
|
||||
keywords: ['rules', 'routing', 'channels', 'severity', 'labels'],
|
||||
tier: 'skipper',
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
adminOnly: true,
|
||||
hiddenOnRemote: true,
|
||||
@@ -161,7 +161,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
label: 'Webhooks',
|
||||
description: 'Incoming HMAC-signed HTTP triggers that run stack actions from CI/CD pipelines.',
|
||||
keywords: ['webhook', 'incoming', 'trigger', 'ci', 'cd', 'pipeline', 'deploy', 'hmac', 'signature', 'action'],
|
||||
tier: 'skipper',
|
||||
tier: null,
|
||||
scope: 'global',
|
||||
hiddenOnRemote: true,
|
||||
},
|
||||
@@ -245,7 +245,6 @@ export interface VisibilityContext {
|
||||
isRemote: boolean;
|
||||
isAdmin: boolean;
|
||||
isPaid: boolean;
|
||||
isAdmiral: boolean;
|
||||
}
|
||||
|
||||
export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
|
||||
@@ -255,7 +254,5 @@ export function isItemVisible(item: SettingsItemMeta, ctx: VisibilityContext): b
|
||||
}
|
||||
|
||||
export function isItemLocked(item: SettingsItemMeta, ctx: VisibilityContext): boolean {
|
||||
if (item.tier === 'skipper') return !ctx.isPaid;
|
||||
if (item.tier === 'admiral') return !ctx.isAdmiral;
|
||||
return false;
|
||||
return item.tier === 'paid' ? !ctx.isPaid : false;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@ import type { BulkAction } from '@/hooks/useBulkStackActions';
|
||||
|
||||
interface SidebarBulkBarProps {
|
||||
selectedCount: number;
|
||||
isPaid: boolean;
|
||||
onAction: (action: BulkAction) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function SidebarBulkBar({ selectedCount, isPaid, onAction, onClear }: SidebarBulkBarProps) {
|
||||
export function SidebarBulkBar({ selectedCount, onAction, onClear }: SidebarBulkBarProps) {
|
||||
return (
|
||||
<div className="px-3 py-2 border-b border-glass-border bg-glass-highlight/20">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -27,9 +26,7 @@ export function SidebarBulkBar({ selectedCount, isPaid, onAction, onClear }: Sid
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('start')}>Start</Button>
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('stop')}>Stop</Button>
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('restart')}>Restart</Button>
|
||||
{isPaid && (
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('update')}>Update</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('update')}>Update</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,6 @@ interface RemoteSearchFailure {
|
||||
export interface StackListProps {
|
||||
files: string[];
|
||||
isLoading: boolean;
|
||||
isPaid: boolean;
|
||||
selectedFile: string | null;
|
||||
searchQuery: string;
|
||||
stackLabelMap: Record<string, Label[]>;
|
||||
@@ -118,7 +117,7 @@ interface StackListBulkProps {
|
||||
|
||||
export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
const {
|
||||
files, isLoading, isPaid, selectedFile, searchQuery, stackLabelMap, stackStatuses,
|
||||
files, isLoading, selectedFile, searchQuery, stackLabelMap, stackStatuses,
|
||||
stackUpdates, gitSourcePendingMap, pinnedFiles, isCollapsed, toggleCollapse,
|
||||
isBusy, getDisplayName, onSelectFile, buildMenuCtx,
|
||||
bulkMode, selectedFiles, onToggleSelect,
|
||||
@@ -179,7 +178,6 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
status={stackStatuses[file] ?? 'unknown'}
|
||||
isBusy={isBusy(file)}
|
||||
isActive={selectedFile === file}
|
||||
isPaid={isPaid}
|
||||
labels={stackLabelMap[file] ?? []}
|
||||
hasUpdate={!!stackUpdates[file]}
|
||||
hasGitPending={!!gitSourcePendingMap[file]}
|
||||
|
||||
@@ -15,7 +15,6 @@ interface StackRowProps {
|
||||
status: StackRowStatus;
|
||||
isBusy: boolean;
|
||||
isActive: boolean;
|
||||
isPaid: boolean;
|
||||
labels: Label[];
|
||||
hasUpdate: boolean;
|
||||
hasGitPending: boolean;
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface StackSidebarProps {
|
||||
onActivityAction: (action: SidebarActivityAction) => void;
|
||||
bulkMode: boolean;
|
||||
selectedFiles: Set<string>;
|
||||
isPaid: boolean;
|
||||
onToggleBulkMode: () => void;
|
||||
onToggleSelect: (file: string) => void;
|
||||
onClearSelection: () => void;
|
||||
@@ -41,7 +40,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
|
||||
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
|
||||
list, activitySummary, onActivityAction,
|
||||
bulkMode, selectedFiles, isPaid, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
|
||||
bulkMode, selectedFiles, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
|
||||
} = props;
|
||||
|
||||
const [filtersVisible, setFiltersVisible] = useState(() => {
|
||||
@@ -84,7 +83,6 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
{selectedFiles.size > 0 && (
|
||||
<SidebarBulkBar
|
||||
selectedCount={selectedFiles.size}
|
||||
isPaid={isPaid}
|
||||
onAction={onBulkAction}
|
||||
onClear={onClearSelection}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,6 @@ function base(overrides: Partial<ComponentProps<typeof StackRow>> = {}) {
|
||||
status: 'running' as const,
|
||||
isBusy: false,
|
||||
isActive: false,
|
||||
isPaid: true,
|
||||
labels: [] as Label[],
|
||||
hasUpdate: false,
|
||||
hasGitPending: false,
|
||||
@@ -74,12 +73,12 @@ describe('StackRow', () => {
|
||||
expect(screen.queryByText('UP')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders label indicators on community tier', () => {
|
||||
it('renders label indicators', () => {
|
||||
const labels: Label[] = [
|
||||
{ id: 1, node_id: 0, name: 'prod', color: 'teal' },
|
||||
{ id: 2, node_id: 0, name: 'media', color: 'blue' },
|
||||
];
|
||||
const { container } = render(<StackRow {...base({ isPaid: false, labels })} />);
|
||||
const { container } = render(<StackRow {...base({ labels })} />);
|
||||
expect(container.querySelectorAll('[style*="--label-"]')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,8 +25,6 @@ export interface StackMenuCtx {
|
||||
stackStatus: StackLifecycleStatus;
|
||||
hasPort: boolean;
|
||||
isBusy: boolean;
|
||||
isPaid: boolean;
|
||||
isAdmiral: boolean;
|
||||
isAdmin: boolean;
|
||||
canDelete: boolean;
|
||||
canEditLabels: boolean;
|
||||
|
||||
Reference in New Issue
Block a user