refactor(frontend): extract shared parts from PaidGate and AdmiralGate (#876)

PaidGate and AdmiralGate were ~95% identical: same state machine
(unlocked / compact-blurred / dismissed-pill / full-upsell-card), same
24h localStorage-backed dismissal logic, differing only in license
predicate, dismiss-storage key, icon, and copy strings. Two reviewers
flagged the duplication after PRs #874 and #875 landed identical
changes in both files; the rule-of-three threshold is met.

Extract the shared parts compositionally rather than as one big config-
driven gate (the latter would just inline both gates' contents behind
8 props of indirection):

- frontend/src/hooks/useDismissalState.ts owns the localStorage
  dismissal pattern. Lives under hooks/ to dodge the
  react-refresh/only-export-components lint rule that would fire if a
  hook coexisted with components in the same file. Validates the stored
  timestamp via Number.isFinite so a hand-edited or stale-extension
  garbage value defaults to "show the upsell" instead of crashing.
- frontend/src/components/tierUpsell.tsx exports CompactBlurredLock,
  DismissedPill, and FullUpsellCard plus a shared TierGateProps
  interface. The compact-mode JSDoc lives on TierGateProps so the doc
  string lives in exactly one place.

PaidGate and AdmiralGate become ~50-line compositions reading like a
state machine. Public API of both gates is byte-stable: all 13+
consumers across the app continue to use <PaidGate featureName="X">
and <AdmiralGate featureName="X" compact> exactly as before.

Two pre-existing security/polish issues fixed in passing while there
is one source of truth for the affected JSX:

- FullUpsellCard's window.open now passes 'noopener,noreferrer' to
  prevent the destination tab from accessing window.opener (reverse
  tabnabbing).
- The Number.parseInt + Number.isFinite guard replaces a bare
  parseInt that would have happily accepted any prefix-numeric input.

Adds a Vitest spec for useDismissalState covering: empty / recent /
expired / non-numeric storage values, dismiss() / restore() side
effects, the 24h boundary on fresh mount, and key independence.
This commit is contained in:
Anso
2026-05-02 04:01:54 -04:00
committed by GitHub
parent b843b89ca4
commit 677f0778e7
5 changed files with 353 additions and 165 deletions
+32 -83
View File
@@ -1,106 +1,55 @@
import { type ReactNode, useState } from 'react';
import { ShipWheel } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useLicense } from '@/context/LicenseContext';
interface AdmiralGateProps {
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.
compact?: boolean;
}
import { useDismissalState } from '@/hooks/useDismissalState';
import {
CompactBlurredLock,
DismissedPill,
FullUpsellCard,
type TierGateProps,
} from './tierUpsell';
const DISMISS_KEY = 'sencho-admiral-upgrade-prompt-dismissed';
const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
function isDismissedFromStorage(): boolean {
const dismissedAt = localStorage.getItem(DISMISS_KEY);
return !!dismissedAt && Date.now() - parseInt(dismissedAt, 10) < DISMISS_DURATION_MS;
}
export function AdmiralGate({ children, featureName = 'This feature', compact = false }: AdmiralGateProps) {
/**
* 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.
*/
export function AdmiralGate({ children, featureName = 'This feature', compact = false }: TierGateProps) {
const { isPaid, license } = useLicense();
const [dismissed, setDismissed] = useState(isDismissedFromStorage);
const { dismissed, dismiss, restore } = useDismissalState(DISMISS_KEY);
if (isPaid && license?.variant === 'admiral') return <>{children}</>;
// Inline lock for list items (single SSO provider card, etc.). Renders the
// children blurred behind a small pill so the surrounding list keeps its
// shape; the IP exposure is minor because the children are tiny inline UI,
// and the visual continuity is intentional. Dismissal does not apply here.
const pillText = 'Upgrade to Admiral to unlock';
if (compact) {
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">
<ShipWheel className="w-3 h-3" />
Upgrade to Admiral to unlock
</div>
</div>
</div>
<CompactBlurredLock icon={ShipWheel} pillText={pillText}>
{children}
</CompactBlurredLock>
);
}
// Post-dismissal placeholder for full-page consumers. Renders only the
// pill so the lazy-loaded children behind an Admiral view never mount
// during the dismissal window. Clicking the pill clears the dismissal
// flag and restores the full upsell card so users who dismissed
// accidentally or want to revisit pricing have a way back without
// clearing localStorage.
if (dismissed) {
return (
<div className="flex items-start justify-center pt-8 min-h-[200px]">
<button
type="button"
onClick={() => {
localStorage.removeItem(DISMISS_KEY);
setDismissed(false);
}}
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"
>
<ShipWheel className="w-3 h-3" />
Upgrade to Admiral to unlock
</button>
</div>
);
return <DismissedPill icon={ShipWheel} pillText={pillText} onClick={restore} />;
}
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">
<ShipWheel className="w-8 h-8 text-muted-foreground" />
</div>
<div className="text-center max-w-md">
<h3 className="text-lg font-semibold mb-2">{featureName} requires Sencho Admiral</h3>
<p className="text-sm text-muted-foreground">
<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>.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
size="sm"
onClick={() => {
localStorage.setItem(DISMISS_KEY, Date.now().toString());
setDismissed(true);
}}
>
Dismiss
</Button>
<Button
size="sm"
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
>
<ShipWheel className="w-4 h-4 mr-2" />
Get Admiral
</Button>
</div>
</div>
</>
}
ctaIcon={ShipWheel}
ctaLabel="Get Admiral"
ctaHref="https://sencho.io/pricing"
onDismiss={dismiss}
/>
);
}
+42 -82
View File
@@ -1,105 +1,65 @@
import { type ReactNode, useState } from 'react';
import { Compass } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useLicense } from '@/context/LicenseContext';
interface PaidGateProps {
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.
compact?: boolean;
}
import { useDismissalState } from '@/hooks/useDismissalState';
import {
CompactBlurredLock,
DismissedPill,
FullUpsellCard,
type TierGateProps,
} from './tierUpsell';
const DISMISS_KEY = 'sencho-upgrade-prompt-dismissed';
const DISMISS_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours (session-like)
function isDismissedFromStorage(): boolean {
const dismissedAt = localStorage.getItem(DISMISS_KEY);
return !!dismissedAt && Date.now() - parseInt(dismissedAt, 10) < DISMISS_DURATION_MS;
}
export function PaidGate({ children, featureName = 'This feature', compact = false }: PaidGateProps) {
/**
* Gate for any paid-tier feature (Skipper or Admiral). Composes the
* shared tier-upsell primitives in `./tierUpsell` according to the
* current state:
*
* 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.
*/
export function PaidGate({ children, featureName = 'This feature', compact = false }: TierGateProps) {
const { isPaid } = useLicense();
const [dismissed, setDismissed] = useState(isDismissedFromStorage);
const { dismissed, dismiss, restore } = useDismissalState(DISMISS_KEY);
if (isPaid) return <>{children}</>;
// Inline lock for list items (single SSO provider card, etc.). Renders the
// children blurred behind a small pill so the surrounding list keeps its
// shape; the IP exposure is minor because the children are tiny inline UI,
// and the visual continuity is intentional. Dismissal does not apply here.
const pillText = `Upgrade to unlock ${featureName}`;
if (compact) {
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">
<Compass className="w-3 h-3" />
Upgrade to unlock {featureName}
</div>
</div>
</div>
<CompactBlurredLock icon={Compass} pillText={pillText}>
{children}
</CompactBlurredLock>
);
}
// Post-dismissal placeholder for full-page consumers. Renders only the
// pill so the lazy-loaded children behind a paid view never mount during
// the dismissal window. Clicking the pill clears the dismissal flag and
// restores the full upsell card so users who dismissed accidentally or
// want to revisit pricing have a way back without clearing localStorage.
if (dismissed) {
return (
<div className="flex items-start justify-center pt-8 min-h-[200px]">
<button
type="button"
onClick={() => {
localStorage.removeItem(DISMISS_KEY);
setDismissed(false);
}}
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"
>
<Compass className="w-3 h-3" />
Upgrade to unlock {featureName}
</button>
</div>
);
return <DismissedPill icon={Compass} pillText={pillText} onClick={restore} />;
}
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">
<Compass className="w-8 h-8 text-muted-foreground" />
</div>
<div className="text-center max-w-md">
<h3 className="text-lg font-semibold mb-2">{featureName} requires a paid license</h3>
<p className="text-sm text-muted-foreground">
<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>.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
size="sm"
onClick={() => {
localStorage.setItem(DISMISS_KEY, Date.now().toString());
setDismissed(true);
}}
>
Dismiss
</Button>
<Button
size="sm"
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
>
<Compass className="w-4 h-4 mr-2" />
View Plans
</Button>
</div>
</div>
</>
}
ctaIcon={Compass}
ctaLabel="View Plans"
ctaHref="https://sencho.io/pricing"
onDismiss={dismiss}
/>
);
}
+147
View File
@@ -0,0 +1,147 @@
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>
);
}
@@ -0,0 +1,92 @@
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);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { 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.
*
* Each hook instance owns its own React state. If two `<PaidGate>`
* mounts share the same key in the same tab, dismissing one does not
* sync to the other until that other re-mounts. Cross-instance pub-sub
* is not provided; the dismissal is a soft UX nicety, not a security
* boundary.
*/
export function useDismissalState(key: string) {
const [dismissed, setDismissed] = useState(() => {
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;
});
const dismiss = () => {
localStorage.setItem(key, Date.now().toString());
setDismissed(true);
};
const restore = () => {
localStorage.removeItem(key);
setDismissed(false);
};
return { dismissed, dismiss, restore };
}