From 1b9bc9482e3b49ed67e23fe7d2ba1a7e8af67b3b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 10 Aug 2026 00:02:45 +0100 Subject: [PATCH] feat(setup): point unlicensed Pro installs at license activation A fresh Pro install has no way to discover where its activation key goes: the Plans & Billing nav entry is navigation-suppressed until a license or subscription exists (hideWhenUpgradeHidden), which hides the only panel holding the activation form exactly while the install is unlicensed. A Pro customer hit this after the broker download flow and emailed support. The setup completion panel now probes /api/license/runtime-capabilities and /api/license/status with the setup token and, only when the compiled Pro build reports no valid license, renders an Activate Pulse Pro card linking straight to /settings/pulse-intelligence/billing/plan (direct routes stay reachable by design). Community builds, licensed installs, and failed probes all keep the card hidden, so the self-hosted opt-in commercial posture is untouched. The pro-unlicensed preview scenario keeps browser proof deterministic; strings are localized in EN/DE/ES; agent-lifecycle, frontend-primitives, and security-privacy contracts record the new handoff and catalog boundaries. --- .../v6/internal/subsystems/agent-lifecycle.md | 13 ++ .../subsystems/frontend-primitives.md | 10 + .../internal/subsystems/security-privacy.md | 7 + frontend-modern/browser-verification.json | 51 ++--- .../SetupWizard/SetupCompletionPanel.tsx | 52 +++++ .../SetupWizard/SetupCompletionPreview.tsx | 1 + .../SetupCompletionPanel.guardrails.test.ts | 22 +++ .../__tests__/SetupCompletionPreview.test.tsx | 20 ++ .../SetupCompletionProActivation.test.tsx | 186 ++++++++++++++++++ .../SetupWizard/setupCompletionModel.ts | 17 ++ .../setupCompletionPreviewScenarios.ts | 14 +- .../src/i18n/__tests__/i18n.test.ts | 16 ++ frontend-modern/src/i18n/messages.de.ts | 4 + frontend-modern/src/i18n/messages.es.ts | 4 + frontend-modern/src/i18n/messages.ts | 7 + 15 files changed, 399 insertions(+), 25 deletions(-) create mode 100644 frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionProActivation.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 60c6faa76..7dd485c2d 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -5449,6 +5449,19 @@ primitive, carry localized names for the exact value being copied, and retain a 40-pixel mobile touch floor. The setup surface must not reintroduce raw, unlabelled icon buttons or a phone-only copy-control fork. +The setup completion surface also owns the Pro activation handoff. When the +compiled Pro edition reports no valid license (both read with the setup +token from `/api/license/runtime-capabilities` and `/api/license/status`, +gated by `shouldShowSetupProActivationPointer` requiring an explicitly +invalid license, never a failed or missing probe), the completion panel +renders a factual activation card whose action targets the direct +`/settings/pulse-intelligence/billing/plan` route. That direct-route target +is deliberate: the Plans & Billing navigation entry stays +navigation-suppressed for unlicensed self-hosted sessions, so the pointer +must not instruct users to find a hidden nav item, and it must never appear +on community builds, licensed installs, or probe failures — the self-hosted +opt-in commercial posture is unchanged. + Agent-facing action completion events and resource-context history now expose the canonical `ActionResultV2` alongside bounded legacy projections. Consumers must branch on execution and verification separately: a completed transport is diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 732900fac..38f62f227 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2117,6 +2117,16 @@ default` instead of fusing provider and badge text such as `/api/state` runtime or inherit whatever connected systems happen to exist on the current backend, and browser proof for `/preview/setup-complete` must select explicit preview scenarios instead of ambient runtime state. + That determinism boundary also covers the setup completion Pro activation + pointer: every preview scenario in + `frontend-modern/src/components/SetupWizard/setupCompletionPreviewScenarios.ts` + must carry an explicit `proActivation` boolean so preview rendering never + falls through to the live license probe, and the `pro-unlicensed` scenario + is the canonical browser proof for the pointer. The pointer's localized + strings (`setup.completion.proActivation.*`) are part of the first-session + monitoring journey catalog and must stay in + `FIRST_SESSION_MONITORING_MIGRATED_MESSAGE_KEYS` with non-identical DE/ES + translations. 13. Keep AI settings setup UI backend-driven: `frontend-modern/src/components/Settings/useAISettingsState.ts` and `frontend-modern/src/components/Settings/AISettingsDialogs.tsx` may collect diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 3e94fe0ef..f697d0616 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -353,6 +353,13 @@ the `white_label` branding entitlement. only for redirect/manual-link copy and must preserve `Pulse Account`, security/privacy disclosures, token names, API field names, route/query keys, and purchase-return state exactly. + The first-session setup completion Pro activation pointer + (`setup.completion.proActivation.*`) is likewise product-surface copy in the + shared catalogs: it must stay a factual statement of build edition and + license state plus the activation action, must not add upgrade-marketing + language, and must not alter API token, authentication, privacy-disclosure, + or non-translatable security terms. `Pulse Pro` stays untranslated as a + product identifier across locales. Alerts-owned localization may distinguish detector state, in-product alert visibility, and external notification delivery, but it must preserve all security/privacy terminology and destination confidentiality. Pausing diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 5d7a8b0e3..322ee14c7 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,45 +1,50 @@ { "version": 1, - "base_sha": "cd63e0848abe224967ff5429e56bfcf06c5860c6", - "verified_at": "2026-08-09T19:42:00Z", + "base_sha": "a7849c9ef04ababee688135394c81adca61c0ea2", + "verified_at": "2026-08-09T22:58:23Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx", - "frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx", - "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts", - "frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx" + "frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx", + "frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx", + "frontend-modern/src/components/SetupWizard/setupCompletionModel.ts", + "frontend-modern/src/components/SetupWizard/setupCompletionPreviewScenarios.ts", + "frontend-modern/src/i18n/messages.de.ts", + "frontend-modern/src/i18n/messages.es.ts", + "frontend-modern/src/i18n/messages.ts" ], "content_sha256": { - "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "99368f451d9153a735180d0f3889bf0d7983af6bce2f9e3012f87b7ad594d265", - "frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "595c0509e2c93574ccea5669f06c5143d4ece7e4772906b8d41d04392e0431cc", - "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "baf908e004f6805b4dfcd013a139ee822a7e91589fa4d086a9440cabfb6c812a", - "frontend-modern/src/components/Settings/useInfrastructureInstallState.tsx": "9a1a2c2d9f7712e1fbf4dbc462d75fe56c79c63aed809196e780b74e2cbd0381" + "frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx": "c349eb0a08633a2f331b2c79c4aa5f74f27d62c1342d2dd1d3a69558d05c6ac3", + "frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx": "15d130f8b6cad7079db2215e4df5ab9d282bdb03f0e644578e515086e47e24c8", + "frontend-modern/src/components/SetupWizard/setupCompletionModel.ts": "0111a04873eff8c028ffdeb8a9616e60ce54e72177c83445631413144eb13355", + "frontend-modern/src/components/SetupWizard/setupCompletionPreviewScenarios.ts": "224c4424ac0934c42275adc2f7dad0c536adf06b245120cfcf7cf2b83592bdf3", + "frontend-modern/src/i18n/messages.de.ts": "8f1131c143c64e207460a32a9b78a20289084c3a1ada0ea3f7581c1ef63c7abd", + "frontend-modern/src/i18n/messages.es.ts": "5595cc905c70d2a4bfc8f7541edc733896449dac8ee9af518d6824bb613295b0", + "frontend-modern/src/i18n/messages.ts": "467ceeefc62f6f65f33db541de98e830168fa195863cac3cb5afd9c0495f7142" }, "routes": [ - "/settings/infrastructure?add=linux-host", - "/settings/infrastructure?agentDoctor=1" + "/preview/setup-complete?scenario=pro-unlicensed", + "/preview/setup-complete", + "/settings/pulse-intelligence/billing/plan" ], "viewports": [ { "width": 1440, - "height": 900 + "height": 1600 }, { "width": 390, - "height": 844 + "height": 1500 } ], "states": [ - "The host installer started with a reporting-only token and copy-ready commands, then immediately locked and removed the previous credential from every rendered command while a command-enabled replacement token was pending.", - "Successful replacement restored the advanced options with Pulse command execution checked and exposed only commands containing the new exec-scoped token.", - "A failed attempt to remove command execution restored the complete prior checked option and exec-scoped token pair, retained copy-ready commands, and surfaced the minting error instead of composing mixed state.", - "Agent Doctor rendered agent_exec_scope_missing as a critical credential diagnosis with explicit agent:exec evidence and a token-gated host-local authentication repair.", - "After Agent Doctor generated the repair credential, the request carried enableCommands=true and the unlocked repair command used an ephemeral token file plus --enable-commands." + "The pro-unlicensed preview scenario rendered the Activate Pulse Pro card between the credentials card and the next-step card, with the factual license description and the Enter activation key button, in dark desktop and light 390px mobile viewports.", + "The default preview scenario rendered the completion panel with no Activate Pulse Pro text anywhere in the document, confirming the pointer is absent for community sessions.", + "The live dev backend (Pro binary, unlicensed Community tier) served /settings/pulse-intelligence/billing/plan as the full Plans & Billing panel including the License recovery section with the Paste your license key input, confirming the pointer's destination renders the activation form rather than blanking.", + "Console showed only WebSocket sync-reconnect noise from the ad-hoc Vite proxy; no errors from the new panel code." ], "interactions": [ - "At 1440x900 generated a reporting token, opened advanced install options, enabled Pulse command execution, held the replacement API response, and confirmed the command surface locked with no old token visible before releasing the exec-scoped replacement.", - "At 390x844 forced the next replacement request to fail, confirmed the checkbox and prior token rolled back atomically, and verified the page had no document-level horizontal overflow and the error remained visible.", - "Opened Agent Doctor against a structured agent_exec_scope_missing response, expanded the critical agent row, verified the typed reason and evidence, generated its repair token, and confirmed the backend request explicitly selected command scope.", - "Verified the unlocked Agent Doctor authentication-repair command contained the fresh credential, token-file transport, and --enable-commands, then repeated the critical row inspection at 390x844 with no document-level horizontal overflow." + "Clicked Enter activation key on the pro-unlicensed preview scenario and confirmed client-side navigation landed on /settings/pulse-intelligence/billing/plan.", + "Logged into the live dev backend through the sidecar Vite proxy and loaded /settings/pulse-intelligence/billing/plan to inspect the rendered license recovery form.", + "Reloaded the preview route across dark 1440px and light 390px viewports to confirm layout and theming of the activation card." ] } diff --git a/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx b/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx index d3cf3e71f..f1e1f8021 100644 --- a/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx +++ b/frontend-modern/src/components/SetupWizard/SetupCompletionPanel.tsx @@ -7,20 +7,24 @@ import { getPulseBaseUrl } from '@/utils/url'; import { ActionIconButton } from '@/components/shared/Button'; import type { State } from '@/types/api'; import type { Resource } from '@/types/resource'; +import type { LicenseRuntimeCapabilities, LicenseStatus } from '@/api/license'; import { buildInfrastructureOnboardingPath, buildInfrastructureWorkspacePath, } from '@/components/Settings/infrastructureWorkspaceModel'; +import { SELF_HOSTED_PRO_BILLING_PLAN_ROUTE } from '@/utils/pricingHandoff'; import type { WizardState } from '../SetupWizard'; import { buildSetupCompletionConnectedSystems, buildSetupCompletionViewModel, + shouldShowSetupProActivationPointer, } from './setupCompletionModel'; interface CompleteStepProps { state: WizardState; onComplete: (nextPath?: string) => void; connectedResourcesOverride?: readonly Resource[]; + proActivationOverride?: boolean; } const ADD_INFRASTRUCTURE_PATH = buildInfrastructureOnboardingPath('pick'); @@ -31,10 +35,43 @@ export const SetupCompletionPanel: Component = (props) => { const [copied, setCopied] = createSignal<'password' | 'admin-token' | null>(null); let copiedTimer: ReturnType | undefined; const [showCredentials, setShowCredentials] = createSignal(true); + const [showProActivation, setShowProActivation] = createSignal(false); const [connectedSystems, setConnectedSystems] = createSignal< ReturnType >([]); + createEffect(() => { + if (props.proActivationOverride !== undefined) { + // Preview scenarios force the flag so browser proof stays deterministic + // without touching live license endpoints. + setShowProActivation(props.proActivationOverride); + return; + } + + void (async () => { + try { + const [capabilities, licenseStatus] = await Promise.all([ + apiFetchJSON('/api/license/runtime-capabilities', { + headers: { 'X-API-Token': props.state.apiToken }, + }), + apiFetchJSON('/api/license/status', { + headers: { 'X-API-Token': props.state.apiToken }, + }), + ]); + setShowProActivation( + shouldShowSetupProActivationPointer({ + runtimeBuild: capabilities?.runtime?.build, + licenseValid: licenseStatus?.valid, + }), + ); + } catch (error) { + // A failed probe keeps the pointer hidden; community installs must + // never see it because a license endpoint errored. + logger.error('Failed to check Pro activation state:', error); + } + })(); + }); + createEffect(() => { if (props.connectedResourcesOverride !== undefined) { // Preview scenarios provide a static resource snapshot so browser proof @@ -388,6 +425,21 @@ export const SetupCompletionPanel: Component = (props) => { + +
+

+ {t('setup.completion.proActivation.title')} +

+

{t('setup.completion.proActivation.description')}

+ +
+
+
{ return ( { if (nextPath) { diff --git a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts index a438c7516..c998174de 100644 --- a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts +++ b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPanel.guardrails.test.ts @@ -30,6 +30,9 @@ const migratedFirstSessionCopy = [ 'Inventory and health from Proxmox, TrueNAS, VMware, PBS, or PMG.', 'Node-local telemetry for standalone hosts, services, Docker, and Kubernetes.', 'Open Add infrastructure to choose a platform API, Pulse Agent, or both.', + 'Activate Pulse Pro', + 'Enter the activation key from your purchase email', + 'Enter activation key', ]; const migratedSources = [ @@ -196,4 +199,23 @@ describe('SetupCompletionPanel guardrails', () => { expect(setupCompletionPanelSource).toContain('setup.completion.action.copyAdminToken'); expect(setupCompletionPanelSource).toContain('min-h-10 min-w-10'); }); + + it('keeps the Pro activation pointer on the direct billing route behind an explicit license gate', () => { + expect(setupCompletionPanelSource).toContain( + "import { SELF_HOSTED_PRO_BILLING_PLAN_ROUTE } from '@/utils/pricingHandoff';", + ); + expect(setupCompletionPanelSource).toContain( + 'props.onComplete(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE)', + ); + expect(setupCompletionPanelSource).toContain("'/api/license/runtime-capabilities'"); + expect(setupCompletionPanelSource).toContain("'/api/license/status'"); + expect(setupCompletionPanelSource).toContain('shouldShowSetupProActivationPointer'); + expect(setupCompletionPanelSource).toContain('props.proActivationOverride !== undefined'); + expect(setupCompletionPanelSource).toContain('setup.completion.proActivation.title'); + expect(setupCompletionPanelSource).toContain('setup.completion.proActivation.description'); + expect(setupCompletionPanelSource).toContain('setup.completion.proActivation.action'); + expect(setupCompletionModelSource).toContain( + "state.runtimeBuild === 'pro' && state.licenseValid === false", + ); + }); }); diff --git a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx index 008177aeb..baccbdf8a 100644 --- a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx +++ b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx @@ -71,4 +71,24 @@ describe('SetupCompletionPreview', () => { expect(screen.getByText('VMware vSphere')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add infrastructure' })).toBeInTheDocument(); }); + + it('renders the pro-unlicensed scenario with the activation pointer and no license probe', () => { + locationSearch = '?scenario=pro-unlicensed'; + + render(() => ); + + expect(apiFetchJSONMock).not.toHaveBeenCalled(); + expect(screen.getByText('Activate Pulse Pro')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Enter activation key' })); + + expect(navigateMock).toHaveBeenCalledWith('/settings/pulse-intelligence/billing/plan'); + }); + + it('keeps the activation pointer out of the default preview scenario', () => { + render(() => ); + + expect(apiFetchJSONMock).not.toHaveBeenCalled(); + expect(screen.queryByText('Activate Pulse Pro')).not.toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionProActivation.test.tsx b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionProActivation.test.tsx new file mode 100644 index 000000000..d702c368f --- /dev/null +++ b/frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionProActivation.test.tsx @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { SetupCompletionPanel } from '../SetupCompletionPanel'; +import { shouldShowSetupProActivationPointer } from '../setupCompletionModel'; +import type { WizardState } from '../SetupWizard'; + +const apiFetchJSONMock = vi.fn(); + +vi.mock('@/utils/clipboard', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), +})); + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: (...args: unknown[]) => apiFetchJSONMock(...args), +})); + +vi.mock('@/utils/url', () => ({ + getPulseBaseUrl: () => 'https://pulse.example.com', +})); + +vi.mock('@/utils/logger', () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +const baseState: WizardState = { + username: 'admin', + password: 'password', + apiToken: 'setup-token', +}; + +const mockLicenseEndpoints = ({ + build, + licenseValid, +}: { + build?: string; + licenseValid?: boolean; +}) => { + apiFetchJSONMock.mockImplementation((path: string) => { + if (path === '/api/license/runtime-capabilities') { + return Promise.resolve({ + capabilities: [], + limits: [], + runtime: build ? { build, label: `Pulse ${build} runtime` } : undefined, + }); + } + if (path === '/api/license/status') { + return Promise.resolve({ + valid: licenseValid, + tier: licenseValid ? 'pro' : 'free', + is_lifetime: false, + days_remaining: 0, + features: [], + }); + } + return Promise.resolve({ resources: [] }); + }); +}; + +const waitForLicenseProbe = async () => { + await waitFor(() => { + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/license/status', + expect.objectContaining({ headers: { 'X-API-Token': 'setup-token' } }), + ); + }); +}; + +describe('shouldShowSetupProActivationPointer', () => { + it('shows only for a Pro build with an explicitly invalid license', () => { + expect(shouldShowSetupProActivationPointer({ runtimeBuild: 'pro', licenseValid: false })).toBe( + true, + ); + expect(shouldShowSetupProActivationPointer({ runtimeBuild: 'pro', licenseValid: true })).toBe( + false, + ); + expect( + shouldShowSetupProActivationPointer({ runtimeBuild: 'community', licenseValid: false }), + ).toBe(false); + expect( + shouldShowSetupProActivationPointer({ runtimeBuild: undefined, licenseValid: false }), + ).toBe(false); + expect( + shouldShowSetupProActivationPointer({ runtimeBuild: 'pro', licenseValid: undefined }), + ).toBe(false); + }); +}); + +describe('SetupCompletionPanel Pro activation pointer', () => { + beforeEach(() => { + vi.clearAllMocks(); + apiFetchJSONMock.mockResolvedValue({ resources: [] }); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders the pointer from the preview override without touching license endpoints', () => { + const onComplete = vi.fn(); + render(() => ( + + )); + + expect(apiFetchJSONMock).not.toHaveBeenCalled(); + expect(screen.getByText('Activate Pulse Pro')).toBeInTheDocument(); + expect( + screen.getByText( + 'This server is running the Pulse Pro build without an active license. Enter the activation key from your purchase email to unlock Pro features.', + ), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Enter activation key' })); + + expect(onComplete).toHaveBeenCalledWith('/settings/pulse-intelligence/billing/plan'); + }); + + it('hides the pointer when the preview override is false', () => { + render(() => ( + + )); + + expect(apiFetchJSONMock).not.toHaveBeenCalled(); + expect(screen.queryByText('Activate Pulse Pro')).not.toBeInTheDocument(); + }); + + it('shows the pointer when a Pro build reports no valid license', async () => { + mockLicenseEndpoints({ build: 'pro', licenseValid: false }); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText('Activate Pulse Pro')).toBeInTheDocument(); + }); + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/license/runtime-capabilities', + expect.objectContaining({ headers: { 'X-API-Token': 'setup-token' } }), + ); + }); + + it('keeps the pointer hidden on a community build', async () => { + mockLicenseEndpoints({ build: 'community', licenseValid: false }); + + render(() => ); + + await waitForLicenseProbe(); + expect(screen.queryByText('Activate Pulse Pro')).not.toBeInTheDocument(); + }); + + it('keeps the pointer hidden on a Pro build with an active license', async () => { + mockLicenseEndpoints({ build: 'pro', licenseValid: true }); + + render(() => ); + + await waitForLicenseProbe(); + expect(screen.queryByText('Activate Pulse Pro')).not.toBeInTheDocument(); + }); + + it('keeps the pointer hidden when the license probe fails', async () => { + apiFetchJSONMock.mockImplementation((path: string) => { + if (path === '/api/license/runtime-capabilities' || path === '/api/license/status') { + return Promise.reject(new Error('probe failed')); + } + return Promise.resolve({ resources: [] }); + }); + + render(() => ); + + await waitForLicenseProbe(); + expect(screen.queryByText('Activate Pulse Pro')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/SetupWizard/setupCompletionModel.ts b/frontend-modern/src/components/SetupWizard/setupCompletionModel.ts index 4e9b7682f..7e7acce1b 100644 --- a/frontend-modern/src/components/SetupWizard/setupCompletionModel.ts +++ b/frontend-modern/src/components/SetupWizard/setupCompletionModel.ts @@ -250,3 +250,20 @@ export function buildSetupCompletionViewModel( showAgentInstallAction: false, }; } + +export interface SetupCompletionProActivationState { + runtimeBuild?: string; + licenseValid?: boolean; +} + +// The Pro build is only distributed to paying customers, and its Plans & +// Billing navigation entry stays hidden until a license activates +// (hideWhenUpgradeHidden), so the completion screen is the one guaranteed +// in-product surface that can point a fresh Pro install at the activation +// form. licenseValid must be explicitly false: a failed or missing probe keeps +// the pointer hidden so community sessions can never see it by accident. +export function shouldShowSetupProActivationPointer( + state: SetupCompletionProActivationState, +): boolean { + return state.runtimeBuild === 'pro' && state.licenseValid === false; +} diff --git a/frontend-modern/src/components/SetupWizard/setupCompletionPreviewScenarios.ts b/frontend-modern/src/components/SetupWizard/setupCompletionPreviewScenarios.ts index faf9bde08..0e8b806ef 100644 --- a/frontend-modern/src/components/SetupWizard/setupCompletionPreviewScenarios.ts +++ b/frontend-modern/src/components/SetupWizard/setupCompletionPreviewScenarios.ts @@ -2,11 +2,14 @@ import type { Resource } from '@/types/resource'; const PREVIEW_TIMESTAMP_MS = Date.UTC(2026, 3, 10, 12, 0, 0, 0); -export type SetupCompletionPreviewScenarioId = 'empty' | 'vmware-api-backed'; +export type SetupCompletionPreviewScenarioId = 'empty' | 'vmware-api-backed' | 'pro-unlicensed'; export interface SetupCompletionPreviewScenario { id: SetupCompletionPreviewScenarioId; resources: readonly Resource[]; + // Always explicit so the preview never falls through to the live license + // probe: the panel treats undefined as "fetch", and preview must not fetch. + proActivation: boolean; } const SETUP_COMPLETION_PREVIEW_SCENARIOS: Record< @@ -16,6 +19,7 @@ const SETUP_COMPLETION_PREVIEW_SCENARIOS: Record< empty: { id: 'empty', resources: [], + proActivation: false, }, 'vmware-api-backed': { id: 'vmware-api-backed', @@ -35,6 +39,12 @@ const SETUP_COMPLETION_PREVIEW_SCENARIOS: Record< }, }, ], + proActivation: false, + }, + 'pro-unlicensed': { + id: 'pro-unlicensed', + resources: [], + proActivation: true, }, }; @@ -45,7 +55,7 @@ export const getSetupCompletionPreviewScenario = ( search: string | null | undefined, ): SetupCompletionPreviewScenario => { const scenario = new URLSearchParams(search || '').get('scenario'); - if (scenario === 'vmware-api-backed') { + if (scenario === 'vmware-api-backed' || scenario === 'pro-unlicensed') { return SETUP_COMPLETION_PREVIEW_SCENARIOS[scenario]; } return SETUP_COMPLETION_PREVIEW_SCENARIOS[DEFAULT_SETUP_COMPLETION_PREVIEW_SCENARIO_ID]; diff --git a/frontend-modern/src/i18n/__tests__/i18n.test.ts b/frontend-modern/src/i18n/__tests__/i18n.test.ts index a97c4bd4c..bbce17ebf 100644 --- a/frontend-modern/src/i18n/__tests__/i18n.test.ts +++ b/frontend-modern/src/i18n/__tests__/i18n.test.ts @@ -242,6 +242,22 @@ describe('i18n foundation', () => { } }); + it('keeps the setup completion Pro activation pointer in the localized first-session journey', () => { + expect(LOCALIZED_FIRST_SESSION_MONITORING_JOURNEY_KEYS).toEqual( + expect.arrayContaining([ + 'setup.completion.proActivation.action', + 'setup.completion.proActivation.description', + 'setup.completion.proActivation.title', + ]), + ); + for (const locale of FIRST_LOCALIZATION_LOCALES) { + expect(I18N_MESSAGES[locale]['setup.completion.proActivation.title']).toContain('Pulse Pro'); + expect(I18N_MESSAGES[locale]['setup.completion.proActivation.description']).toContain( + 'Pulse Pro', + ); + } + }); + it('keeps machine-facing identifiers unchanged in first-session monitoring catalog copy', () => { for (const locale of FIRST_LOCALIZATION_LOCALES) { const telemetryNotice = I18N_MESSAGES[locale]['setup.welcome.telemetryNotice.description']; diff --git a/frontend-modern/src/i18n/messages.de.ts b/frontend-modern/src/i18n/messages.de.ts index 89bba3173..276de6bca 100644 --- a/frontend-modern/src/i18n/messages.de.ts +++ b/frontend-modern/src/i18n/messages.de.ts @@ -197,6 +197,10 @@ export const DE_MESSAGE_OVERRIDES = { 'Oeffnen Sie Infrastruktur hinzufuegen, um eine Plattform-API, Pulse Agent oder beides zu waehlen.', 'setup.completion.nextStep.title.connected': 'Infrastruktur oeffnen', 'setup.completion.nextStep.title.empty': 'Strategie fuer erste Quelle waehlen', + 'setup.completion.proActivation.action': 'Aktivierungsschluessel eingeben', + 'setup.completion.proActivation.description': + 'Dieser Server laeuft mit dem Pulse Pro Build, aber ohne aktive Lizenz. Geben Sie den Aktivierungsschluessel aus Ihrer Kauf-E-Mail ein, um die Pro-Funktionen freizuschalten.', + 'setup.completion.proActivation.title': 'Pulse Pro aktivieren', 'setup.completion.resource.unknownName': 'Unbekannt', 'setup.completion.sourceOptions.agent.description': 'Knotenlokale Telemetrie fuer eigenstaendige Hosts, Dienste, Docker und Kubernetes.', diff --git a/frontend-modern/src/i18n/messages.es.ts b/frontend-modern/src/i18n/messages.es.ts index d56ee9cd1..aa63bf0f0 100644 --- a/frontend-modern/src/i18n/messages.es.ts +++ b/frontend-modern/src/i18n/messages.es.ts @@ -191,6 +191,10 @@ export const ES_MESSAGE_OVERRIDES = { 'Abre Agregar infraestructura para elegir una API de plataforma, Pulse Agent o ambos.', 'setup.completion.nextStep.title.connected': 'Abrir Infraestructura', 'setup.completion.nextStep.title.empty': 'Elige la estrategia de primera fuente', + 'setup.completion.proActivation.action': 'Introducir clave de activación', + 'setup.completion.proActivation.description': + 'Este servidor ejecuta la compilación Pulse Pro sin una licencia activa. Introduce la clave de activación de tu correo de compra para desbloquear las funciones Pro.', + 'setup.completion.proActivation.title': 'Activar Pulse Pro', 'setup.completion.resource.unknownName': 'Desconocido', 'setup.completion.sourceOptions.agent.description': 'Telemetría local del nodo para hosts independientes, servicios, Docker y Kubernetes.', diff --git a/frontend-modern/src/i18n/messages.ts b/frontend-modern/src/i18n/messages.ts index 79ed73f2a..1d828c6cc 100644 --- a/frontend-modern/src/i18n/messages.ts +++ b/frontend-modern/src/i18n/messages.ts @@ -56,6 +56,10 @@ export const EN_MESSAGES = { 'Open Add infrastructure to choose a platform API, Pulse Agent, or both.', 'setup.completion.nextStep.title.connected': 'Open Infrastructure', 'setup.completion.nextStep.title.empty': 'Choose the first source strategy', + 'setup.completion.proActivation.action': 'Enter activation key', + 'setup.completion.proActivation.description': + 'This server is running the Pulse Pro build without an active license. Enter the activation key from your purchase email to unlock Pro features.', + 'setup.completion.proActivation.title': 'Activate Pulse Pro', 'setup.completion.resource.unknownName': 'Unknown', 'setup.completion.sourceOptions.agent.description': 'Node-local telemetry for standalone hosts, services, Docker, and Kubernetes.', @@ -591,6 +595,9 @@ export const FIRST_SESSION_MONITORING_MIGRATED_MESSAGE_KEYS = [ 'setup.completion.nextStep.summary.empty', 'setup.completion.nextStep.title.connected', 'setup.completion.nextStep.title.empty', + 'setup.completion.proActivation.action', + 'setup.completion.proActivation.description', + 'setup.completion.proActivation.title', 'setup.completion.resource.unknownName', 'setup.completion.sourceOptions.agent.description', 'setup.completion.sourceOptions.agent.title',