mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 08:10:26 +00:00
feat(license): global banner for pending or failed v5 license migrations
The only surface explaining an unresolved v5→v6 license migration was an inline notice buried in Settings → Billing → Plan, so a paying customer whose migration stalled discovered it as silently missing Pro features. Mount a global banner in the app shell (same slot as the demo and update banners) whenever commercial posture carries a pending or failed commercial_migration state. It reuses getCommercialMigrationNotice for copy, links to the licence panel, dismisses per session and per state (a pending→failed transition resurfaces it), re-checks posture every minute while pending so the backend's background retry clears it without a reload, and loads commercial posture itself — the app shell only loads posture when upgrade prompts are enabled, and a migration warning is service degradation, not an upgrade nag. Verified with vitest (component + presentation suites) and live against the dev instance with an undecryptable license.enc fixture.
This commit is contained in:
@@ -8,6 +8,7 @@ import { Login } from './components/Login';
|
||||
import { logger } from './utils/logger';
|
||||
import { UpdateBanner } from './components/UpdateBanner';
|
||||
import { DemoBanner } from './components/DemoBanner';
|
||||
import { CommercialMigrationBanner } from './components/CommercialMigrationBanner';
|
||||
import { GitHubStarBanner } from './components/GitHubStarBanner';
|
||||
// Modals are only mounted when opened, so their code can stay out of the
|
||||
// entry bundle until first use (same pattern as AIChat below).
|
||||
@@ -454,6 +455,7 @@ function App() {
|
||||
<Show when={!kioskMode()}>
|
||||
<SecurityWarning />
|
||||
<DemoBanner />
|
||||
<CommercialMigrationBanner />
|
||||
<UpdateBanner />
|
||||
<GitHubStarBanner />
|
||||
<GlobalUpdateProgressWatcher />
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import {
|
||||
commercialPosture,
|
||||
commercialPostureLoaded,
|
||||
loadCommercialPosture,
|
||||
} from '@/stores/licenseCommercial';
|
||||
import { sessionPresentationPolicyResolved } from '@/stores/sessionPresentationPolicy';
|
||||
import { getCommercialMigrationNotice } from '@/utils/licensePresentation';
|
||||
import { SELF_HOSTED_PRO_BILLING_PLAN_ROUTE } from '@/utils/pricingHandoff';
|
||||
|
||||
const DISMISSED_KEY_PREFIX = 'commercialMigrationBannerDismissed:';
|
||||
const PENDING_RECHECK_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Global banner for an unresolved paid v5 license migration. The Settings
|
||||
* licence panel already explains the state in detail, but a paying customer
|
||||
* who upgraded should not have to discover missing Pro features before
|
||||
* learning their migration needs attention.
|
||||
*/
|
||||
export function CommercialMigrationBanner() {
|
||||
const navigate = useNavigate();
|
||||
const [dismissedState, setDismissedState] = createSignal<string | null>(null);
|
||||
|
||||
const migration = createMemo(() => commercialPosture()?.commercial_migration);
|
||||
const notice = createMemo(() => getCommercialMigrationNotice(migration()));
|
||||
|
||||
// Dismissal is per session and per migration state, so a pending → failed
|
||||
// transition resurfaces the banner. The sessionStorage check lives in the
|
||||
// memo because posture arrives asynchronously after mount.
|
||||
const dismissed = createMemo(() => {
|
||||
const state = migration()?.state;
|
||||
if (!state) return false;
|
||||
return (
|
||||
dismissedState() === state ||
|
||||
sessionStorage.getItem(DISMISSED_KEY_PREFIX + state) === 'true'
|
||||
);
|
||||
});
|
||||
|
||||
// The app shell only loads commercial posture when upgrade prompts are
|
||||
// enabled, but a migration warning is service degradation, not an upgrade
|
||||
// nag — load the banner's own data dependency regardless.
|
||||
createEffect(() => {
|
||||
if (sessionPresentationPolicyResolved() && !commercialPostureLoaded()) {
|
||||
void loadCommercialPosture();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// A pending migration is retried by the backend in the background, so
|
||||
// re-check periodically and let the banner clear itself on success.
|
||||
const timer = setInterval(() => {
|
||||
if (commercialPosture()?.commercial_migration?.state === 'pending') {
|
||||
void loadCommercialPosture(true);
|
||||
}
|
||||
}, PENDING_RECHECK_INTERVAL_MS);
|
||||
onCleanup(() => clearInterval(timer));
|
||||
});
|
||||
|
||||
const handleDismiss = () => {
|
||||
const state = migration()?.state;
|
||||
if (!state) return;
|
||||
setDismissedState(state);
|
||||
sessionStorage.setItem(DISMISSED_KEY_PREFIX + state, 'true');
|
||||
};
|
||||
|
||||
const toneClasses = createMemo(() =>
|
||||
migration()?.state === 'failed'
|
||||
? 'bg-red-50 dark:bg-red-900 border-b border-red-200 dark:border-red-800 text-red-800 dark:text-red-100'
|
||||
: 'bg-amber-50 dark:bg-amber-900 border-b border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-100',
|
||||
);
|
||||
const buttonClasses = createMemo(() =>
|
||||
migration()?.state === 'failed'
|
||||
? 'border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-800'
|
||||
: 'border-amber-300 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-800',
|
||||
);
|
||||
|
||||
return (
|
||||
<Show when={!dismissed() && notice()}>
|
||||
{(activeNotice) => (
|
||||
<div class={`px-3 py-2 ${toneClasses()}`}>
|
||||
<div class="container mx-auto flex items-center justify-between gap-3 text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">
|
||||
<span class="font-medium">{activeNotice().title}.</span> {activeNotice().body}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE)}
|
||||
class={`px-2 py-1 rounded border transition-colors whitespace-nowrap ${buttonClasses()}`}
|
||||
>
|
||||
Open license settings
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
class="p-1 rounded transition-colors opacity-70 hover:opacity-100"
|
||||
title="Dismiss for this session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CommercialMigrationStatus } from '@/api/license';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mocks */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const commercialPostureMock = vi.hoisted(() => vi.fn());
|
||||
const commercialPostureLoadedMock = vi.hoisted(() => vi.fn());
|
||||
const loadCommercialPostureMock = vi.hoisted(() => vi.fn());
|
||||
const sessionPresentationPolicyResolvedMock = vi.hoisted(() => vi.fn());
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/stores/licenseCommercial', () => ({
|
||||
commercialPosture: () => commercialPostureMock(),
|
||||
commercialPostureLoaded: () => commercialPostureLoadedMock(),
|
||||
loadCommercialPosture: (force?: boolean) => loadCommercialPostureMock(force),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/sessionPresentationPolicy', () => ({
|
||||
sessionPresentationPolicyResolved: () => sessionPresentationPolicyResolvedMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@solidjs/router', () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function setMigration(migration?: CommercialMigrationStatus) {
|
||||
commercialPostureMock.mockReturnValue(
|
||||
migration === undefined
|
||||
? { tier: 'free', commercial_migration: undefined }
|
||||
: { tier: 'free', commercial_migration: migration },
|
||||
);
|
||||
}
|
||||
|
||||
async function renderBanner() {
|
||||
const { CommercialMigrationBanner } = await import('../CommercialMigrationBanner');
|
||||
render(() => <CommercialMigrationBanner />);
|
||||
}
|
||||
|
||||
describe('CommercialMigrationBanner', () => {
|
||||
beforeEach(() => {
|
||||
commercialPostureMock.mockReset();
|
||||
commercialPostureMock.mockReturnValue(null);
|
||||
commercialPostureLoadedMock.mockReset();
|
||||
commercialPostureLoadedMock.mockReturnValue(true);
|
||||
loadCommercialPostureMock.mockReset();
|
||||
sessionPresentationPolicyResolvedMock.mockReset();
|
||||
sessionPresentationPolicyResolvedMock.mockReturnValue(true);
|
||||
navigateMock.mockReset();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/* ---------- Visibility ---------- */
|
||||
|
||||
it('stays hidden when there is no migration state', async () => {
|
||||
setMigration(undefined);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText(/v5 license migration/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a pending migration with the panel notice copy', async () => {
|
||||
setMigration({
|
||||
source: 'v5_license',
|
||||
state: 'pending',
|
||||
reason: 'exchange_unavailable',
|
||||
recommended_action: 'retry_activation',
|
||||
});
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.getByText(/v5 license migration pending/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/automatic v6 exchange did not complete yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a failed migration including the unreadable persisted license reason', async () => {
|
||||
setMigration({
|
||||
source: 'v5_license',
|
||||
state: 'failed',
|
||||
reason: 'persisted_license_unreadable',
|
||||
recommended_action: 'enter_supported_v5_key',
|
||||
});
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.getByText(/v5 license migration needs attention/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/could not be read on this system/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
/* ---------- Action ---------- */
|
||||
|
||||
it('navigates to the license settings panel from the action button', async () => {
|
||||
setMigration({
|
||||
source: 'v5_license',
|
||||
state: 'pending',
|
||||
reason: 'exchange_unavailable',
|
||||
recommended_action: 'retry_activation',
|
||||
});
|
||||
|
||||
await renderBanner();
|
||||
|
||||
fireEvent.click(screen.getByText('Open license settings'));
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith('/settings/system/billing/plan');
|
||||
});
|
||||
|
||||
/* ---------- Dismiss ---------- */
|
||||
|
||||
it('dismisses for the session, but resurfaces when the state changes', async () => {
|
||||
setMigration({
|
||||
source: 'v5_license',
|
||||
state: 'pending',
|
||||
reason: 'exchange_unavailable',
|
||||
recommended_action: 'retry_activation',
|
||||
});
|
||||
|
||||
await renderBanner();
|
||||
|
||||
fireEvent.click(screen.getByTitle('Dismiss for this session'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/v5 license migration pending/)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(sessionStorage.getItem('commercialMigrationBannerDismissed:pending')).toBe('true');
|
||||
|
||||
// A pending → failed transition must resurface the banner.
|
||||
setMigration({
|
||||
source: 'v5_license',
|
||||
state: 'failed',
|
||||
reason: 'exchange_invalid',
|
||||
recommended_action: 'enter_supported_v5_key',
|
||||
});
|
||||
cleanup();
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.getByText(/v5 license migration needs attention/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays hidden when the current state was already dismissed this session', async () => {
|
||||
sessionStorage.setItem('commercialMigrationBannerDismissed:pending', 'true');
|
||||
setMigration({
|
||||
source: 'v5_license',
|
||||
state: 'pending',
|
||||
reason: 'exchange_unavailable',
|
||||
recommended_action: 'retry_activation',
|
||||
});
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(screen.queryByText(/v5 license migration pending/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/* ---------- Data dependency ---------- */
|
||||
|
||||
it('loads commercial posture itself when the app shell has not', async () => {
|
||||
commercialPostureLoadedMock.mockReturnValue(false);
|
||||
setMigration(undefined);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(loadCommercialPostureMock).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('does not reload posture when the app shell already loaded it', async () => {
|
||||
setMigration(undefined);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(loadCommercialPostureMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers the posture load until the presentation policy resolves', async () => {
|
||||
commercialPostureLoadedMock.mockReturnValue(false);
|
||||
sessionPresentationPolicyResolvedMock.mockReturnValue(false);
|
||||
setMigration(undefined);
|
||||
|
||||
await renderBanner();
|
||||
|
||||
expect(loadCommercialPostureMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/* ---------- Self-healing ---------- */
|
||||
|
||||
it('re-checks posture while pending so the banner clears after background migration', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
setMigration({
|
||||
source: 'v5_license',
|
||||
state: 'pending',
|
||||
reason: 'exchange_unavailable',
|
||||
recommended_action: 'retry_activation',
|
||||
});
|
||||
|
||||
await renderBanner();
|
||||
|
||||
vi.advanceTimersByTime(61_000);
|
||||
expect(loadCommercialPostureMock).toHaveBeenCalledWith(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user