mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
feat(ui): hide paid features from community-tier dashboard (#891)
* feat(ui): hide paid features from community-tier dashboard Community installs render only the features they can use. Tier-locked sections, lock badges, upsell cards, and "Upgrade" buttons no longer appear anywhere except the License page in Settings, which is the single discoverable upgrade path. Concretely: - PaidGate and AdmiralGate now render null for non-qualifying tiers instead of upsell cards. - SectionGate (settings) hides tier-locked sections entirely. - Settings sidebar and command palette filter out items the operator cannot reach. - Configuration Status widget on the dashboard drops the Automation section for community and hides any locked rows in remaining sections. - Fleet > Status node cards drop locked summary rows. - Stack action menu, sidebar bulk bar, file upload / download, scan comparison, network topology toggle, node label picker all hide for community instead of showing disabled affordances or "Upgrade" literal text. - Removes tierUpsell, TierLockChip, and useDismissalState (no longer referenced). Backend tier guards remain authoritative; this changes UI discovery only. * test(e2e): assert upload control is absent in community tier The community-clean-ui change removes the "Upgrade to unlock upload" pill from the file explorer. Update the matching e2e assertion to verify the upload control is not rendered, instead of waiting for a pill that no longer exists.
This commit is contained in:
@@ -196,10 +196,8 @@ test.describe('File explorer - community (read-only)', () => {
|
||||
await context.unroute('/api/license');
|
||||
});
|
||||
|
||||
test('upgrade pill is visible in the left pane', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: /upgrade to unlock upload/i })
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
test('upload control is absent in community tier', async ({ page }) => {
|
||||
await expect(page.getByLabel('Upload file')).toHaveCount(0, { timeout: 1_000 });
|
||||
});
|
||||
|
||||
test('can expand config/ and click config/app.conf - Save button is absent', async ({ page }) => {
|
||||
|
||||
@@ -1,55 +1,15 @@
|
||||
import { ShipWheel } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useDismissalState } from '@/hooks/useDismissalState';
|
||||
import {
|
||||
CompactBlurredLock,
|
||||
DismissedPill,
|
||||
FullUpsellCard,
|
||||
type TierGateProps,
|
||||
} from './tierUpsell';
|
||||
|
||||
const DISMISS_KEY = 'sencho-admiral-upgrade-prompt-dismissed';
|
||||
|
||||
/**
|
||||
* Gate for Admiral-tier-only features. Mirrors PaidGate's state machine
|
||||
* but with a stricter license predicate (requires variant === 'admiral')
|
||||
* and Admiral-themed icon/copy.
|
||||
* Thin wrapper that renders its children only for licensees on the
|
||||
* Admiral plan. All other tiers (Community, Skipper) see nothing in
|
||||
* this slot. Backend tier guards (`requireAdmiral`) remain the
|
||||
* authoritative enforcement; this component only controls UI
|
||||
* visibility.
|
||||
*/
|
||||
export function AdmiralGate({ children, featureName = 'This feature', compact = false }: TierGateProps) {
|
||||
export function AdmiralGate({ children }: { children: ReactNode }) {
|
||||
const { isPaid, license } = useLicense();
|
||||
const { dismissed, dismiss, restore } = useDismissalState(DISMISS_KEY);
|
||||
|
||||
if (isPaid && license?.variant === 'admiral') return <>{children}</>;
|
||||
|
||||
const pillText = 'Upgrade to Admiral to unlock';
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<CompactBlurredLock icon={ShipWheel} pillText={pillText}>
|
||||
{children}
|
||||
</CompactBlurredLock>
|
||||
);
|
||||
}
|
||||
|
||||
if (dismissed) {
|
||||
return <DismissedPill icon={ShipWheel} pillText={pillText} onClick={restore} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FullUpsellCard
|
||||
icon={ShipWheel}
|
||||
title={`${featureName} requires Sencho Admiral`}
|
||||
body={
|
||||
<>
|
||||
Unlock team features like LDAP / Active Directory, audit logging, API tokens, and unlimited user accounts with a Sencho Admiral license.
|
||||
For enterprise pricing or questions, contact{' '}
|
||||
<a href="mailto:licensing@sencho.io" className="text-brand hover:underline">licensing@sencho.io</a>.
|
||||
</>
|
||||
}
|
||||
ctaIcon={ShipWheel}
|
||||
ctaLabel="Get Admiral"
|
||||
ctaHref="https://sencho.io/pricing"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||
return isAdmiral ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ export function ApiTokensSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdmiralGate featureName="API Tokens">
|
||||
<AdmiralGate>
|
||||
<CapabilityGate capability="api-tokens" featureName="API Tokens">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -642,7 +642,7 @@ function AutoUpdateReadinessContent() {
|
||||
|
||||
export default function AutoUpdateReadinessView() {
|
||||
return (
|
||||
<PaidGate featureName="Auto-Update Readiness">
|
||||
<PaidGate>
|
||||
<AutoUpdateReadinessContent />
|
||||
</PaidGate>
|
||||
);
|
||||
|
||||
@@ -2522,7 +2522,7 @@ export default function EditorLayout() {
|
||||
) : activeView === 'resources' ? (
|
||||
<ResourcesView />
|
||||
) : activeView === 'host-console' ? (
|
||||
<AdmiralGate featureName="Host Console">
|
||||
<AdmiralGate>
|
||||
<CapabilityGate capability="host-console" featureName="Host Console">
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={<ViewSkeleton />}>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI
|
||||
import { springs } from '@/lib/motion';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { PaidGate } from './PaidGate';
|
||||
import { AdmiralGate } from './AdmiralGate';
|
||||
import FleetSnapshots from './FleetSnapshots';
|
||||
import { FleetConfiguration } from './fleet/FleetConfiguration';
|
||||
@@ -1354,24 +1353,6 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Free tier: paid gate for advanced features */}
|
||||
{!isPaid && nodes.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<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 border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
|
||||
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
|
||||
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
|
||||
<div className="rounded-xl border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 h-24" />
|
||||
</div>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="h-9 rounded-md border border-card-border bg-card flex-1 max-w-sm" />
|
||||
<div className="h-9 rounded-md border border-card-border bg-card w-[150px]" />
|
||||
</div>
|
||||
</PaidGate>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -1383,7 +1364,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
)}
|
||||
{isAdmiral && experimental && (
|
||||
<TabsContent value="routing">
|
||||
<AdmiralGate featureName="Sencho Mesh">
|
||||
<AdmiralGate>
|
||||
<RoutingTab />
|
||||
</AdmiralGate>
|
||||
</TabsContent>
|
||||
@@ -1397,15 +1378,13 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
{isPaid ? (
|
||||
<DeploymentsTab />
|
||||
) : (
|
||||
<PaidGate featureName="Blueprints (fleet-wide compose templates)">
|
||||
<FleetSoonPlaceholder
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
kicker="Deployments · Blueprints"
|
||||
title="Declare once. Distribute everywhere."
|
||||
description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional."
|
||||
plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']}
|
||||
/>
|
||||
</PaidGate>
|
||||
<FleetSoonPlaceholder
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
kicker="Deployments · Blueprints"
|
||||
title="Declare once. Distribute everywhere."
|
||||
description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional."
|
||||
plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="federation">
|
||||
|
||||
@@ -562,7 +562,7 @@ export function NodeManager() {
|
||||
{isPaid ? (
|
||||
<NodeLabelPicker nodeId={node.id} canEdit={canEditLabels} />
|
||||
) : (
|
||||
<span className="text-[10px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Upgrade</span>
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -1,65 +1,18 @@
|
||||
import { Compass } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useDismissalState } from '@/hooks/useDismissalState';
|
||||
import {
|
||||
CompactBlurredLock,
|
||||
DismissedPill,
|
||||
FullUpsellCard,
|
||||
type TierGateProps,
|
||||
} from './tierUpsell';
|
||||
|
||||
const DISMISS_KEY = 'sencho-upgrade-prompt-dismissed';
|
||||
|
||||
/**
|
||||
* Gate for any paid-tier feature (Skipper or Admiral). Composes the
|
||||
* shared tier-upsell primitives in `./tierUpsell` according to the
|
||||
* current state:
|
||||
* Thin wrapper that renders its children only for licensees on a paid
|
||||
* plan (Skipper or Admiral). Community-tier users see nothing in this
|
||||
* slot. Backend tier guards (`requirePaid`) remain the authoritative
|
||||
* enforcement; this component only controls UI visibility.
|
||||
*
|
||||
* isPaid render children (unlocked).
|
||||
* compact blurred children + small pill (inline list items).
|
||||
* dismissed pill-only placeholder for 24h, click to restore.
|
||||
* default full upsell card with View Plans CTA.
|
||||
*
|
||||
* The compact branch retains the blurred-children render because it is
|
||||
* used for tiny inline UI where the visual continuity is intentional.
|
||||
* The dismissed and default branches do not render children, so any
|
||||
* lazy chunks behind the gate are never fetched on those paths.
|
||||
* Use only when wrapping a discrete fragment that has no neighboring
|
||||
* context for Community users. Where possible, prefer a parent-level
|
||||
* `useLicense().isPaid` check that lifts the visibility decision out
|
||||
* of the rendering tree entirely.
|
||||
*/
|
||||
export function PaidGate({ children, featureName = 'This feature', compact = false }: TierGateProps) {
|
||||
export function PaidGate({ children }: { children: ReactNode }) {
|
||||
const { isPaid } = useLicense();
|
||||
const { dismissed, dismiss, restore } = useDismissalState(DISMISS_KEY);
|
||||
|
||||
if (isPaid) return <>{children}</>;
|
||||
|
||||
const pillText = `Upgrade to unlock ${featureName}`;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<CompactBlurredLock icon={Compass} pillText={pillText}>
|
||||
{children}
|
||||
</CompactBlurredLock>
|
||||
);
|
||||
}
|
||||
|
||||
if (dismissed) {
|
||||
return <DismissedPill icon={Compass} pillText={pillText} onClick={restore} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FullUpsellCard
|
||||
icon={Compass}
|
||||
title={`${featureName} requires a paid license`}
|
||||
body={
|
||||
<>
|
||||
Unlock features like fleet management, viewer accounts, one-click Google / GitHub / Okta SSO, and more with a Skipper or Admiral license.
|
||||
For enterprise pricing or questions, contact{' '}
|
||||
<a href="mailto:licensing@sencho.io" className="text-brand hover:underline">licensing@sencho.io</a>.
|
||||
</>
|
||||
}
|
||||
ctaIcon={Compass}
|
||||
ctaLabel="View Plans"
|
||||
ctaHref="https://sencho.io/pricing"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
return isPaid ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ export function RegistriesSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdmiralGate featureName="Private Registry Management">
|
||||
<AdmiralGate>
|
||||
<CapabilityGate capability="registries" featureName="Private Registries">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -984,27 +984,28 @@ export default function ResourcesView() {
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pr-3">
|
||||
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setNetworkViewMode('list')}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
|
||||
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setNetworkViewMode('topology')}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
|
||||
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Topology
|
||||
{!isPaid && <Badge variant="outline" className="text-[9px] h-4 px-1 border-brand/30 text-brand">Skipper</Badge>}
|
||||
</button>
|
||||
</div>
|
||||
{isPaid && (
|
||||
<div className="flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={() => setNetworkViewMode('list')}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200',
|
||||
networkViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setNetworkViewMode('topology')}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-200 flex items-center gap-1',
|
||||
networkViewMode === 'topology' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Topology
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && networkViewMode === 'list' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1021,7 +1022,7 @@ export default function ResourcesView() {
|
||||
|
||||
{networkViewMode === 'topology' ? (
|
||||
<div className="p-4">
|
||||
<PaidGate featureName="Network Topology">
|
||||
<PaidGate>
|
||||
<CapabilityGate capability="network-topology" featureName="Network Topology">
|
||||
<LazyBoundary>
|
||||
<Suspense fallback={
|
||||
|
||||
@@ -397,9 +397,9 @@ function ProviderCardWithGate(props: {
|
||||
const card = <ProviderCard {...props} />;
|
||||
if (props.providerId === 'oidc_custom') return card;
|
||||
if (props.providerId === 'ldap') {
|
||||
return <AdmiralGate compact featureName="LDAP / Active Directory">{card}</AdmiralGate>;
|
||||
return <AdmiralGate>{card}</AdmiralGate>;
|
||||
}
|
||||
return <PaidGate compact featureName={`${props.label} SSO`}>{card}</PaidGate>;
|
||||
return <PaidGate>{card}</PaidGate>;
|
||||
}
|
||||
|
||||
export function SSOSection() {
|
||||
|
||||
@@ -164,23 +164,21 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps)
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<SheetTitle className="font-display italic text-2xl">Scan history</SheetTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-border"
|
||||
onClick={compareSelected}
|
||||
disabled={compareDisabled || !isPaid}
|
||||
title={
|
||||
!isPaid
|
||||
? 'Scan comparison requires Skipper or Admiral'
|
||||
: compareDisabled
|
||||
? 'Select exactly two completed scans to compare'
|
||||
: 'Compare the two selected scans'
|
||||
}
|
||||
>
|
||||
<GitCompare className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Compare ({selected.length}/2)
|
||||
</Button>
|
||||
{isPaid && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-border"
|
||||
onClick={compareSelected}
|
||||
disabled={compareDisabled}
|
||||
title={compareDisabled
|
||||
? 'Select exactly two completed scans to compare'
|
||||
: 'Compare the two selected scans'}
|
||||
>
|
||||
<GitCompare className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Compare ({selected.length}/2)
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -312,7 +312,7 @@ export function StackAutoHealSheet({ stackName, open, onOpenChange }: StackAutoH
|
||||
];
|
||||
|
||||
return (
|
||||
<PaidGate featureName="Auto-Heal Policies">
|
||||
<PaidGate>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-[440px] flex flex-col">
|
||||
<SheetHeader>
|
||||
|
||||
@@ -211,7 +211,7 @@ describe('SecurityHistoryView', () => {
|
||||
expect(mockedFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables Compare button for community tier', async () => {
|
||||
it('hides Compare button for community tier', async () => {
|
||||
licenseState.isPaid = false;
|
||||
mockedFetch.mockResolvedValue(
|
||||
listResponse([
|
||||
@@ -226,7 +226,6 @@ describe('SecurityHistoryView', () => {
|
||||
await user.click(checkboxes[0]);
|
||||
await user.click(checkboxes[1]);
|
||||
|
||||
const compareBtn = screen.getByRole('button', { name: /Compare \(2\/2\)/ });
|
||||
expect(compareBtn).toBeDisabled();
|
||||
expect(screen.queryByRole('button', { name: /Compare/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -8,19 +9,7 @@ interface ConfigurationStatusProps {
|
||||
onOpenSection?: (section: SectionId) => void;
|
||||
}
|
||||
|
||||
function StatusBadge({ value, locked, requiredTier }: {
|
||||
value: string;
|
||||
locked?: boolean;
|
||||
requiredTier?: string;
|
||||
}) {
|
||||
if (locked && requiredTier) {
|
||||
const label = requiredTier === 'admiral' ? 'Admiral' : 'Skipper';
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-warning/80">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
function StatusBadge({ value }: { value: string }) {
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === 'on' || lower === 'enabled') {
|
||||
return (
|
||||
@@ -41,14 +30,12 @@ function StatusBadge({ value, locked, requiredTier }: {
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, locked, requiredTier, onClick }: {
|
||||
function Row({ label, value, onClick }: {
|
||||
label: string;
|
||||
value: string;
|
||||
locked?: boolean;
|
||||
requiredTier?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const labelClass = `text-xs ${locked ? 'text-stat-subtitle/60' : 'text-stat-subtitle'}`;
|
||||
const labelClass = 'text-xs text-stat-subtitle';
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
@@ -60,7 +47,7 @@ function Row({ label, value, locked, requiredTier, onClick }: {
|
||||
<div className="flex items-center justify-between py-1 px-1">
|
||||
<span className={`${labelClass} group-hover:text-stat-value transition-colors`}>{label}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusBadge value={value} locked={locked} requiredTier={requiredTier} />
|
||||
<StatusBadge value={value} />
|
||||
<ChevronRight className="h-3 w-3 text-stat-icon opacity-0 group-hover:opacity-60 transition-opacity shrink-0" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +58,7 @@ function Row({ label, value, locked, requiredTier, onClick }: {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 px-1 rounded-sm">
|
||||
<span className={labelClass}>{label}</span>
|
||||
<StatusBadge value={value} locked={locked} requiredTier={requiredTier} />
|
||||
<StatusBadge value={value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -96,6 +83,7 @@ function SkeletonRow() {
|
||||
|
||||
export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps = {}) {
|
||||
const { status, loading } = useConfigurationStatus();
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
const open = (section: SectionId) => () => onOpenSection?.(section);
|
||||
|
||||
@@ -160,39 +148,43 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
||||
value={formatCount(notifications.alertRules, 'rule')}
|
||||
onClick={open('notifications')}
|
||||
/>
|
||||
<Row
|
||||
label="Notification routing"
|
||||
value={notifications.routingRules.locked ? '' : formatCount(notifications.routingRules.enabledCount, 'route')}
|
||||
locked={notifications.routingRules.locked}
|
||||
requiredTier={notifications.routingRules.locked ? notifications.routingRules.requiredTier : undefined}
|
||||
onClick={open('notification-routing')}
|
||||
/>
|
||||
{!notifications.routingRules.locked && (
|
||||
<Row
|
||||
label="Notification routing"
|
||||
value={formatCount(notifications.routingRules.enabledCount, 'route')}
|
||||
onClick={open('notification-routing')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 stacks"
|
||||
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total}`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Webhooks"
|
||||
value={automation.webhooks.locked ? '' : formatCount(automation.webhooks.enabled, 'active')}
|
||||
locked={automation.webhooks.locked}
|
||||
requiredTier={automation.webhooks.locked ? automation.webhooks.requiredTier : undefined}
|
||||
onClick={open('webhooks')}
|
||||
/>
|
||||
<Row
|
||||
label="Scheduled tasks"
|
||||
value={automation.scheduledTasks.locked ? '' : formatCount(automation.scheduledTasks.enabled, 'active')}
|
||||
locked={automation.scheduledTasks.locked}
|
||||
requiredTier={automation.scheduledTasks.locked ? automation.scheduledTasks.requiredTier : undefined}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
{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 stacks"
|
||||
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total}`}
|
||||
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" />
|
||||
<Row
|
||||
@@ -201,22 +193,22 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
||||
onClick={open('account')}
|
||||
/>
|
||||
<Row label="SSO" value={ssoLabel} onClick={open('sso')} />
|
||||
<Row
|
||||
label="Vulnerability scanning"
|
||||
value={security.scanPolicies.locked ? '' : formatCount(security.scanPolicies.enabled, 'policy')}
|
||||
locked={security.scanPolicies.locked}
|
||||
requiredTier={security.scanPolicies.locked ? security.scanPolicies.requiredTier : undefined}
|
||||
onClick={open('security')}
|
||||
/>
|
||||
{!security.scanPolicies.locked && (
|
||||
<Row
|
||||
label="Vulnerability scanning"
|
||||
value={formatCount(security.scanPolicies.enabled, 'policy')}
|
||||
onClick={open('security')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SectionHeader icon={HardDrive} label="Backups & Thresholds" />
|
||||
<Row
|
||||
label="Cloud Backup"
|
||||
value={backup.locked ? '' : backup.provider === 'disabled' ? 'Disabled' : backup.provider === 'sencho' ? 'Sencho Cloud' : `Custom S3${backup.autoUpload ? ' (auto)' : ''}`}
|
||||
locked={backup.locked}
|
||||
requiredTier={backup.locked ? backup.requiredTier : undefined}
|
||||
onClick={open('cloud-backup')}
|
||||
/>
|
||||
{!backup.locked && (
|
||||
<Row
|
||||
label="Cloud Backup"
|
||||
value={backup.provider === 'disabled' ? 'Disabled' : backup.provider === 'sencho' ? 'Sencho Cloud' : `Custom S3${backup.autoUpload ? ' (auto)' : ''}`}
|
||||
onClick={open('cloud-backup')}
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Alert thresholds"
|
||||
value={`CPU ${thresholds.cpuLimit}% · RAM ${thresholds.ramLimit}% · Disk ${thresholds.diskLimit}%`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef } from 'react';
|
||||
import { UploadCloud, Compass } from 'lucide-react';
|
||||
import { UploadCloud } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { uploadStackFile } from '@/lib/stackFilesApi';
|
||||
@@ -20,18 +20,7 @@ export function FileUploadDropzone({
|
||||
const { isPaid } = useLicense();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!isPaid) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs cursor-pointer hover:border-brand/50 transition-colors"
|
||||
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
|
||||
>
|
||||
<Compass className="w-3 h-3" />
|
||||
Upgrade to unlock upload, edit, and delete
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (!isPaid) return null;
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (file.size > MAX_BYTES) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, Suspense } from 'react';
|
||||
import { Editor } from '@/lib/monacoLoader';
|
||||
import { AlertCircle, FileIcon, Download, Lock, Loader2, Save } from 'lucide-react';
|
||||
import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
@@ -72,7 +72,7 @@ function SpecialFilePanel({
|
||||
<p className="font-mono text-sm text-stat-title">{filename}</p>
|
||||
<p className="text-xs text-stat-subtitle">{label} · {formatBytes(size)}</p>
|
||||
</div>
|
||||
{canDownload ? (
|
||||
{canDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -86,13 +86,6 @@ function SpecialFilePanel({
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
) : (
|
||||
<div title="Upgrade to download files">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Lock className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -160,15 +160,14 @@ describe('FileViewer', () => {
|
||||
expect(downloadBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows disabled Download button for community tier', async () => {
|
||||
it('hides Download button for community tier', async () => {
|
||||
licenseState.isPaid = false;
|
||||
mockReadFile.mockResolvedValue(binaryResult());
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
|
||||
|
||||
await screen.findByText(/binary file/i);
|
||||
const downloadBtn = screen.getByRole('button', { name: /download/i });
|
||||
expect(downloadBtn).toBeDisabled();
|
||||
expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-fetches when selectedPath changes', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Bell, Zap, Shield, HardDrive, WifiOff, CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import type { ConfigurationStatusPayload } from '@/components/dashboard';
|
||||
|
||||
interface FleetNodeConfiguration {
|
||||
@@ -16,34 +17,21 @@ interface FleetNodeConfiguration {
|
||||
configuration: ConfigurationStatusPayload | null;
|
||||
}
|
||||
|
||||
function TierChip({ tier }: { tier: string }) {
|
||||
const label = tier === 'admiral' ? 'Admiral' : 'Skipper';
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 text-[10px] leading-3 font-mono tracking-[0.18em] uppercase text-warning/80">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ icon: Icon, label, value, locked, requiredTier }: {
|
||||
function SummaryRow({ icon: Icon, label, value }: {
|
||||
icon: typeof Bell;
|
||||
label: string;
|
||||
value: string;
|
||||
locked?: boolean;
|
||||
requiredTier?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<Icon className="h-3 w-3 shrink-0 text-stat-icon" strokeWidth={1.5} />
|
||||
<span className={`text-xs flex-1 ${locked ? 'text-stat-subtitle/60' : 'text-stat-subtitle'}`}>{label}</span>
|
||||
{locked && requiredTier
|
||||
? <TierChip tier={requiredTier} />
|
||||
: <span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>}
|
||||
<span className="text-xs flex-1 text-stat-subtitle">{label}</span>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ node }: { node: FleetNodeConfiguration }) {
|
||||
function NodeCard({ node, isPaid }: { node: FleetNodeConfiguration; isPaid: boolean }) {
|
||||
const isRemote = node.type === 'remote';
|
||||
if (!node.configuration) {
|
||||
return (
|
||||
@@ -90,39 +78,27 @@ function NodeCard({ node }: { node: FleetNodeConfiguration }) {
|
||||
value={agentCount === 0 ? 'None' : `${agentCount} active`} />
|
||||
<SummaryRow icon={Bell} label="Alert rules"
|
||||
value={formatCount(notifications.alertRules, 'rule')} />
|
||||
<SummaryRow icon={Zap} label="Auto-heal"
|
||||
value={automation.autoHeal.total === 0
|
||||
? 'None'
|
||||
: `${automation.autoHeal.enabled}/${automation.autoHeal.total}`} />
|
||||
<SummaryRow icon={Zap} label="Webhooks"
|
||||
value={
|
||||
automation.webhooks.locked
|
||||
? ''
|
||||
: formatCount(automation.webhooks.enabled, 'active')
|
||||
}
|
||||
locked={automation.webhooks.locked}
|
||||
requiredTier={automation.webhooks.locked ? automation.webhooks.requiredTier : undefined} />
|
||||
{isPaid && (
|
||||
<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')} />
|
||||
)}
|
||||
{!isRemote && (
|
||||
<SummaryRow icon={Shield} label="MFA"
|
||||
value={security.mfaEnabled === null ? 'Not set' : security.mfaEnabled ? 'On' : 'Off'} />
|
||||
)}
|
||||
<SummaryRow icon={Shield} label="Scanning"
|
||||
value={
|
||||
security.scanPolicies.locked
|
||||
? ''
|
||||
: formatCount(security.scanPolicies.enabled, 'policy')
|
||||
}
|
||||
locked={security.scanPolicies.locked}
|
||||
requiredTier={security.scanPolicies.locked ? security.scanPolicies.requiredTier : undefined} />
|
||||
{!isRemote && (
|
||||
{!security.scanPolicies.locked && (
|
||||
<SummaryRow icon={Shield} label="Scanning"
|
||||
value={formatCount(security.scanPolicies.enabled, 'policy')} />
|
||||
)}
|
||||
{!isRemote && !backup.locked && (
|
||||
<SummaryRow icon={HardDrive} label="Backup"
|
||||
value={
|
||||
backup.locked
|
||||
? ''
|
||||
: backup.provider === 'disabled' ? 'Disabled' : 'Enabled'
|
||||
}
|
||||
locked={backup.locked}
|
||||
requiredTier={backup.locked ? backup.requiredTier : undefined} />
|
||||
value={backup.provider === 'disabled' ? 'Disabled' : 'Enabled'} />
|
||||
)}
|
||||
<SummaryRow icon={HardDrive} label="Crash detect"
|
||||
value={thresholds.globalCrash ? 'On' : 'Off'} />
|
||||
@@ -133,6 +109,7 @@ function NodeCard({ node }: { node: FleetNodeConfiguration }) {
|
||||
}
|
||||
|
||||
export function FleetConfiguration() {
|
||||
const { isPaid } = useLicense();
|
||||
const [nodes, setNodes] = useState<FleetNodeConfiguration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -193,7 +170,7 @@ export function FleetConfiguration() {
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
|
||||
{nodes.map(node => <NodeCard key={node.id} node={node} />)}
|
||||
{nodes.map(node => <NodeCard key={node.id} node={node} isPaid={isPaid} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ export function CloudBackupSection() {
|
||||
const usageColor = usagePercent >= 90 ? 'var(--destructive)' : usagePercent >= 80 ? 'var(--warning)' : 'var(--brand)';
|
||||
|
||||
return (
|
||||
<AdmiralGate featureName="Cloud Backup">
|
||||
<AdmiralGate>
|
||||
<div className="space-y-6">
|
||||
<div className={PANEL_CLASS}>
|
||||
<Label className="text-sm">Storage Mode</Label>
|
||||
|
||||
@@ -142,7 +142,7 @@ export function LabelsSection({ onLabelsChanged }: LabelsSectionProps = {}) {
|
||||
};
|
||||
|
||||
return (
|
||||
<PaidGate featureName="Stack Labels">
|
||||
<PaidGate>
|
||||
<CapabilityGate capability="labels" featureName="Stack Labels">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -308,7 +308,7 @@ export function NotificationRoutingSection() {
|
||||
);
|
||||
|
||||
return (
|
||||
<AdmiralGate featureName="Notification Routing">
|
||||
<AdmiralGate>
|
||||
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { getSettingsItem, isItemVisible, isItemLocked } from './registry';
|
||||
import type { VisibilityContext } from './registry';
|
||||
import type { SectionId } from './types';
|
||||
import { LockCard } from '../ui/LockCard';
|
||||
|
||||
interface TierLockedCardProps {
|
||||
tier: 'skipper' | 'admiral';
|
||||
}
|
||||
|
||||
function TierLockedCard({ tier }: TierLockedCardProps) {
|
||||
const title = tier === 'admiral' ? 'Admiral feature' : 'Skipper feature';
|
||||
return (
|
||||
<LockCard icon={Lock} title={title} body="Upgrade to unlock more features." />
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionGateProps {
|
||||
sectionId: SectionId;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the section body only if the registry says it is visible AND
|
||||
* the operator has the entitlement to use it. Tier-locked sections are
|
||||
* hidden entirely from operators who do not qualify. Backend tier
|
||||
* guards remain the authoritative enforcement.
|
||||
*/
|
||||
export function SectionGate({ sectionId, children }: SectionGateProps) {
|
||||
const { isAdmin } = useAuth();
|
||||
const { isPaid, license } = useLicense();
|
||||
@@ -41,15 +34,8 @@ export function SectionGate({ sectionId, children }: SectionGateProps) {
|
||||
|
||||
const item = getSettingsItem(sectionId);
|
||||
|
||||
// SettingsPage routes invisible sections back to a visible default before this
|
||||
// component renders, so reaching this branch means the registry shape changed
|
||||
// mid-session. Render nothing rather than throw — SettingsPage's effect will
|
||||
// resolve to a valid section on the next tick.
|
||||
if (!item || !isItemVisible(item, visibility)) return null;
|
||||
|
||||
if (isItemLocked(item, visibility) && (item.tier === 'skipper' || item.tier === 'admiral')) {
|
||||
return <TierLockedCard tier={item.tier} />;
|
||||
}
|
||||
if (isItemLocked(item, visibility)) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
if (!isPaid) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PaidGate featureName="Scan Policies">
|
||||
<PaidGate>
|
||||
<div className="space-y-3">
|
||||
<div className="h-16 rounded-lg border bg-card" />
|
||||
<div className="h-16 rounded-lg border bg-card" />
|
||||
|
||||
@@ -37,8 +37,6 @@ import LazyBoundary from '../LazyBoundary';
|
||||
import { SectionGate } from './SectionGate';
|
||||
import { SettingsSidebar } from './SettingsSidebar';
|
||||
import { MastheadStatsProvider, useMastheadStatsValue } from './MastheadStatsContext';
|
||||
import { TierLockChip } from './TierLockChip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Paid-tier sections are loaded on demand. SectionGate short-circuits to a
|
||||
// TierLockedCard for Community / wrong-variant operators before reaching the
|
||||
@@ -116,9 +114,10 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
|
||||
// node-scoped item on a remote, or admin-only item for a non-admin), fall back to
|
||||
// the first visible item.
|
||||
const safeSection: SectionId = useMemo(() => {
|
||||
const reachable = (i: SettingsItemMeta) => isItemVisible(i, visibility) && !isItemLocked(i, visibility);
|
||||
const direct = SETTINGS_ITEMS.find(i => i.id === currentSection);
|
||||
if (direct && isItemVisible(direct, visibility)) return direct.id;
|
||||
const fallback = SETTINGS_ITEMS.find(i => isItemVisible(i, visibility));
|
||||
if (direct && reachable(direct)) return direct.id;
|
||||
const fallback = SETTINGS_ITEMS.find(reachable);
|
||||
return fallback?.id ?? 'appearance';
|
||||
}, [currentSection, visibility]);
|
||||
useEffect(() => {
|
||||
@@ -155,8 +154,13 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
|
||||
const activeGroup = activeItem ? getSettingsGroup(activeItem.group) : undefined;
|
||||
const nodeName = activeNode?.name ?? 'local';
|
||||
|
||||
// Items reachable in this session: visible AND not tier-locked. Tier-locked
|
||||
// items are hidden entirely from operators who do not qualify so the
|
||||
// command palette and the sidebar never surface unreachable destinations.
|
||||
const visibleItems = useMemo(
|
||||
() => SETTINGS_ITEMS.filter(item => isItemVisible(item, visibility)),
|
||||
() => SETTINGS_ITEMS.filter(item =>
|
||||
isItemVisible(item, visibility) && !isItemLocked(item, visibility),
|
||||
),
|
||||
[visibility],
|
||||
);
|
||||
|
||||
@@ -282,7 +286,6 @@ function SettingsPageInner({ currentSection, onSectionChange }: SettingsPageProp
|
||||
key={item.id}
|
||||
item={item}
|
||||
glyph={group.glyph}
|
||||
visibility={visibility}
|
||||
onSelect={() => {
|
||||
setCommandOpen(false);
|
||||
onSectionChange(item.id);
|
||||
@@ -305,24 +308,20 @@ function scopeLabel(item: SettingsItemMeta): string {
|
||||
function SettingsCommandItem({
|
||||
item,
|
||||
glyph,
|
||||
visibility,
|
||||
onSelect,
|
||||
}: {
|
||||
item: SettingsItemMeta;
|
||||
glyph: string;
|
||||
visibility: VisibilityContext;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const locked = isItemLocked(item, visibility);
|
||||
const searchValue = [item.label, item.description, ...item.keywords].join(' ').toLowerCase();
|
||||
return (
|
||||
<CommandItem value={searchValue} onSelect={onSelect}>
|
||||
<span className="font-mono text-[10px] w-3 text-center text-stat-subtitle/70">{glyph}</span>
|
||||
<div className={cn('flex flex-col gap-0.5 min-w-0 flex-1', locked && 'opacity-60')}>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-stat-value truncate">{item.label}</span>
|
||||
<span className="text-xs text-stat-subtitle truncate">{item.description}</span>
|
||||
</div>
|
||||
{item.tier && locked ? <TierLockChip tier={item.tier} /> : null}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useNodes } from '@/context/NodeContext';
|
||||
import { SETTINGS_GROUPS, SETTINGS_ITEMS, isItemVisible, isItemLocked } from './registry';
|
||||
import type { VisibilityContext, SettingsItemMeta } from './registry';
|
||||
import type { SectionId } from './types';
|
||||
import { TierLockChip } from './TierLockChip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
@@ -31,8 +30,12 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
|
||||
isRemote,
|
||||
};
|
||||
|
||||
function isVisible(item: SettingsItemMeta): boolean {
|
||||
return isItemVisible(item, visibility);
|
||||
// An item appears in the sidebar only if its registry visibility predicate
|
||||
// passes AND the operator has the entitlement for it. Tier-locked items
|
||||
// are hidden entirely from operators who do not qualify so the Community
|
||||
// surface stays uncluttered. Backend tier guards remain authoritative.
|
||||
function isReachable(item: SettingsItemMeta): boolean {
|
||||
return isItemVisible(item, visibility) && !isItemLocked(item, visibility);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -54,15 +57,11 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
|
||||
<nav className="pb-4">
|
||||
{SETTINGS_GROUPS.map(group => {
|
||||
const groupItems = SETTINGS_ITEMS.filter(
|
||||
item => item.group === group.id && isVisible(item),
|
||||
item => item.group === group.id && isReachable(item),
|
||||
);
|
||||
|
||||
if (groupItems.length === 0) return null;
|
||||
|
||||
const unlockedCount = groupItems.filter(
|
||||
item => !isItemLocked(item, visibility),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div key={group.id} className="mb-1 mt-3">
|
||||
<div className="mb-1 flex items-center justify-between gap-2 px-2">
|
||||
@@ -70,11 +69,10 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
|
||||
{group.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] leading-3 tabular-nums text-stat-subtitle/50">
|
||||
{unlockedCount}/{groupItems.length}
|
||||
{groupItems.length}
|
||||
</span>
|
||||
</div>
|
||||
{groupItems.map(item => {
|
||||
const locked = isItemLocked(item, visibility);
|
||||
const isDirty = dirtyFlags?.[item.id] ?? false;
|
||||
const isActive = item.id === currentSection;
|
||||
|
||||
@@ -89,7 +87,6 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
|
||||
isActive
|
||||
? 'text-stat-value'
|
||||
: 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value',
|
||||
locked && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
@@ -109,7 +106,6 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
|
||||
{isDirty && (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" />
|
||||
)}
|
||||
{item.tier && locked && <TierLockChip tier={item.tier} showIcon={false} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Lock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type TierLockTier = 'skipper' | 'admiral';
|
||||
|
||||
interface TierLockChipProps {
|
||||
tier: TierLockTier;
|
||||
showIcon?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TierLockChip({ tier, showIcon = true, className }: TierLockChipProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-sm border border-card-border bg-card px-1.5 py-0.5 font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle/80',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showIcon && <Lock className="h-2.5 w-2.5" strokeWidth={1.5} />}
|
||||
{tier === 'admiral' ? 'Admiral' : 'Skipper'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -259,7 +259,7 @@ export function UsersSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<PaidGate featureName="User management">
|
||||
<PaidGate>
|
||||
<CapabilityGate capability="users" featureName="User Management">
|
||||
<div className="space-y-6">
|
||||
{!showForm && (
|
||||
|
||||
@@ -7,8 +7,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { PaidGate } from '@/components/PaidGate';
|
||||
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||
import {
|
||||
RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2,
|
||||
Plus, ChevronDown, ChevronRight, History,
|
||||
@@ -154,20 +152,7 @@ export function WebhooksSection({ isPaid }: { isPaid: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isPaid) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PaidGate featureName="Webhooks">
|
||||
<CapabilityGate capability="webhooks" featureName="Webhooks">
|
||||
<div className="space-y-3">
|
||||
<div className="h-16 rounded-lg border bg-card" />
|
||||
<div className="h-16 rounded-lg border bg-card" />
|
||||
</div>
|
||||
</CapabilityGate>
|
||||
</PaidGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!isPaid) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
|
||||
@@ -39,5 +39,4 @@ export { SettingsSection } from './SettingsSection';
|
||||
export { SettingsField, type SettingsFieldTone } from './SettingsField';
|
||||
export { SettingsCallout, type SettingsCalloutTone } from './SettingsCallout';
|
||||
export { SettingsActions, SettingsPrimaryButton, SettingsSecondaryButton } from './SettingsActions';
|
||||
export { TierLockChip } from './TierLockChip';
|
||||
export { useMastheadStats } from './MastheadStatsContext';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { TierBadge } from '@/components/TierBadge';
|
||||
import type { BulkAction } from '@/hooks/useBulkStackActions';
|
||||
|
||||
interface SidebarBulkBarProps {
|
||||
@@ -29,24 +27,8 @@ 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 ? (
|
||||
{isPaid && (
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('update')}>Update</Button>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono gap-1 pointer-events-none opacity-60" disabled>
|
||||
Update
|
||||
<TierBadge tier="paid" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Bulk update requires a Skipper license</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* Shared rendering parts used by `PaidGate` and `AdmiralGate`. The two
|
||||
* gates only differ in their license predicate, dismissal-storage key,
|
||||
* icon, and copy strings; everything else (the compact-blurred-lock
|
||||
* JSX, the dismissed-pill JSX, the full upsell card layout) is
|
||||
* identical. Dismissal-state logic lives in `useDismissalState` under
|
||||
* `frontend/src/hooks/`.
|
||||
*
|
||||
* These primitives let each gate compose its state machine in ~25
|
||||
* lines:
|
||||
*
|
||||
* if (isUnlocked) return children;
|
||||
* if (compact) return <CompactBlurredLock icon pillText>{children}</CompactBlurredLock>;
|
||||
* if (dismissed) return <DismissedPill icon pillText onClick={restore} />;
|
||||
* return <FullUpsellCard icon title body ctaIcon ctaLabel ctaHref onDismiss={dismiss} />;
|
||||
*
|
||||
* A future third gate (Skipper-only, or some hypothetical Enterprise
|
||||
* tier) can mix and match these without re-writing the same JSX.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Public props shape for both `PaidGate` and `AdmiralGate`. Hoisted
|
||||
* here so the `compact` doc string lives in exactly one place.
|
||||
*/
|
||||
export interface TierGateProps {
|
||||
children: ReactNode;
|
||||
featureName?: string;
|
||||
/**
|
||||
* Inline compact lock for list items (e.g. a single SSO provider
|
||||
* card). Skips the full-page upsell and dismiss timer; always
|
||||
* renders the blurred + pill style so the surrounding list keeps
|
||||
* its shape.
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline list-item lock. Renders children blurred behind a small pill
|
||||
* so the surrounding list keeps its shape. Used for tiny inline UI
|
||||
* like a single SSO provider card; the IP exposure of the blurred
|
||||
* children is minor and the visual continuity is intentional.
|
||||
* Dismissal does not apply here.
|
||||
*/
|
||||
export function CompactBlurredLock({
|
||||
icon: Icon,
|
||||
pillText,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
pillText: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="opacity-40 pointer-events-none select-none blur-[2px]">
|
||||
{children}
|
||||
</div>
|
||||
<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">
|
||||
<Icon className="w-3 h-3" />
|
||||
{pillText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-dismissal placeholder. Renders only the pill so any lazy-loaded
|
||||
* children behind a paid view never mount during the 24h dismissal
|
||||
* window. The pill is a button: clicking it calls `onClick` (typically
|
||||
* the gate's `restore` action) so the full upsell card returns and the
|
||||
* user has a way back without clearing localStorage.
|
||||
*/
|
||||
export function DismissedPill({
|
||||
icon: Icon,
|
||||
pillText,
|
||||
onClick,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
pillText: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-center pt-8 min-h-[200px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{pillText}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page upsell card. Centered icon chip + title + body (which can
|
||||
* carry inline links) + Dismiss / CTA actions. The CTA opens the
|
||||
* pricing page in a new tab with `noopener,noreferrer` to prevent the
|
||||
* destination from accessing `window.opener` (reverse tabnabbing).
|
||||
* Dismiss invokes `onDismiss`, which the gate uses to flip dismissal
|
||||
* state.
|
||||
*/
|
||||
export function FullUpsellCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
body,
|
||||
ctaIcon: CtaIcon,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
onDismiss,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
body: ReactNode;
|
||||
ctaIcon: LucideIcon;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-6 p-8">
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-muted/50 border border-border">
|
||||
<Icon className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{body}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" size="sm" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => window.open(ctaHref, '_blank', 'noopener,noreferrer')}>
|
||||
<CtaIcon className="w-4 h-4 mr-2" />
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useDismissalState } from '../useDismissalState';
|
||||
|
||||
const KEY = 'test-dismiss-key';
|
||||
const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('useDismissalState', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns dismissed=false when no timestamp is stored', () => {
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns dismissed=true when a recent timestamp is stored', () => {
|
||||
localStorage.setItem(KEY, String(Date.now() - 1000));
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
expect(result.current.dismissed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns dismissed=false when the stored timestamp is past the 24h window', () => {
|
||||
localStorage.setItem(KEY, String(Date.now() - DISMISS_DURATION_MS - 1));
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns dismissed=false when the stored value is non-numeric garbage', () => {
|
||||
// Future-proofing: a stale extension or hand-edit could leave a non-
|
||||
// numeric value at the key; the hook must not crash and must default
|
||||
// to "show the upsell."
|
||||
localStorage.setItem(KEY, 'not-a-number');
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('dismiss() flips dismissed to true and writes a parseable timestamp', () => {
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
|
||||
act(() => result.current.dismiss());
|
||||
|
||||
expect(result.current.dismissed).toBe(true);
|
||||
const stored = localStorage.getItem(KEY);
|
||||
expect(stored).not.toBeNull();
|
||||
expect(Number.isFinite(Number.parseInt(stored as string, 10))).toBe(true);
|
||||
});
|
||||
|
||||
it('restore() flips dismissed to false and removes the storage entry', () => {
|
||||
localStorage.setItem(KEY, String(Date.now()));
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
expect(result.current.dismissed).toBe(true);
|
||||
|
||||
act(() => result.current.restore());
|
||||
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('respects the 24h boundary: a fresh mount past expiry sees dismissed=false', () => {
|
||||
// Dismiss now, then advance time past the window, then mount fresh.
|
||||
// The original hook instance keeps its `dismissed: true` state in
|
||||
// React (lazy initializer runs once), so the boundary is observable
|
||||
// only on a fresh mount, not via re-render of the same hook.
|
||||
const { result, unmount } = renderHook(() => useDismissalState(KEY));
|
||||
act(() => result.current.dismiss());
|
||||
expect(result.current.dismissed).toBe(true);
|
||||
|
||||
unmount();
|
||||
vi.advanceTimersByTime(DISMISS_DURATION_MS + 1000);
|
||||
|
||||
const fresh = renderHook(() => useDismissalState(KEY));
|
||||
expect(fresh.result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('different keys are independent', () => {
|
||||
const { result: a } = renderHook(() => useDismissalState('key-a'));
|
||||
const { result: b } = renderHook(() => useDismissalState('key-b'));
|
||||
|
||||
act(() => a.current.dismiss());
|
||||
|
||||
expect(a.current.dismissed).toBe(true);
|
||||
expect(b.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('syncs to dismissed=true when another tab fires a storage event with a recent timestamp', () => {
|
||||
// Browsers fire `storage` events only in OTHER tabs than the one
|
||||
// that wrote the change, so this test simulates "tab B receives
|
||||
// a dismiss from tab A" by dispatching a synthetic StorageEvent.
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: KEY,
|
||||
newValue: String(Date.now()),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.dismissed).toBe(true);
|
||||
});
|
||||
|
||||
it('syncs to dismissed=false when another tab fires a storage event with a null newValue', () => {
|
||||
// null newValue is what the spec emits when localStorage.removeItem
|
||||
// is called in another tab.
|
||||
localStorage.setItem(KEY, String(Date.now()));
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
expect(result.current.dismissed).toBe(true);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: KEY,
|
||||
newValue: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores storage events for unrelated keys', () => {
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'unrelated-key',
|
||||
newValue: String(Date.now()),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a storage event carrying a stale timestamp as not-dismissed', () => {
|
||||
const { result } = renderHook(() => useDismissalState(KEY));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: KEY,
|
||||
newValue: String(Date.now() - DISMISS_DURATION_MS - 1),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.dismissed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Tracks the localStorage-backed dismissal flag for an upsell gate.
|
||||
* `dismiss()` writes the current timestamp and flips state to dismissed;
|
||||
* `restore()` removes the timestamp so the next render falls through to
|
||||
* the full upsell card. The dismissal window is 24h; after expiry,
|
||||
* `localStorage.getItem(key)` still returns a stale timestamp but the
|
||||
* lazy initializer treats it as expired and returns `dismissed: false`,
|
||||
* so the gate shows the full upsell again on next mount.
|
||||
*
|
||||
* Cross-tab sync: a `storage` event listener on `window` propagates
|
||||
* dismiss / restore actions from other tabs. The browser fires
|
||||
* `storage` events only on tabs OTHER than the one that wrote the
|
||||
* change, so this listener handles tab B updates after tab A
|
||||
* dismisses or restores; same-tab updates flow through `setDismissed`
|
||||
* directly. Stale timestamps that arrive past the 24h window are
|
||||
* treated as not-dismissed.
|
||||
*/
|
||||
export function useDismissalState(key: string) {
|
||||
const [dismissed, setDismissed] = useState(() => readDismissedFromStorage(key));
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== key) return;
|
||||
// event.newValue is null when the entry was removed (restore)
|
||||
// and a string when it was set (dismiss).
|
||||
if (event.newValue === null) {
|
||||
setDismissed(false);
|
||||
return;
|
||||
}
|
||||
const ts = Number.parseInt(event.newValue, 10);
|
||||
setDismissed(Number.isFinite(ts) && Date.now() - ts < DISMISS_DURATION_MS);
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
// setDismissed is stable per React's setter guarantee; the handler
|
||||
// closes over `key` only. Do not add setDismissed (harmless) or
|
||||
// dismissed (would re-attach the listener on every state change)
|
||||
// to satisfy a future drive-by exhaustive-deps "fix."
|
||||
}, [key]);
|
||||
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(key, Date.now().toString());
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
localStorage.removeItem(key);
|
||||
setDismissed(false);
|
||||
};
|
||||
|
||||
return { dismissed, dismiss, restore };
|
||||
}
|
||||
|
||||
function readDismissedFromStorage(key: string): boolean {
|
||||
const dismissedAt = localStorage.getItem(key);
|
||||
if (!dismissedAt) return false;
|
||||
const ts = Number.parseInt(dismissedAt, 10);
|
||||
if (!Number.isFinite(ts)) return false;
|
||||
return Date.now() - ts < DISMISS_DURATION_MS;
|
||||
}
|
||||
Reference in New Issue
Block a user