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:
rcourtman
2026-06-11 08:46:11 +01:00
parent f9b10316b8
commit 495eec3ce7
3 changed files with 339 additions and 0 deletions
+2
View File
@@ -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();
}
});
});