mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<CompleteStepProps> = (props) => {
|
||||
const [copied, setCopied] = createSignal<'password' | 'admin-token' | null>(null);
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const [showCredentials, setShowCredentials] = createSignal(true);
|
||||
const [showProActivation, setShowProActivation] = createSignal(false);
|
||||
const [connectedSystems, setConnectedSystems] = createSignal<
|
||||
ReturnType<typeof buildSetupCompletionConnectedSystems>
|
||||
>([]);
|
||||
|
||||
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<LicenseRuntimeCapabilities>('/api/license/runtime-capabilities', {
|
||||
headers: { 'X-API-Token': props.state.apiToken },
|
||||
}),
|
||||
apiFetchJSON<LicenseStatus>('/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<CompleteStepProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={showProActivation()}>
|
||||
<div class="bg-surface rounded-md border border-border p-5 sm:p-6 text-left mb-6">
|
||||
<h3 class="text-sm font-semibold text-base-content">
|
||||
{t('setup.completion.proActivation.title')}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-muted">{t('setup.completion.proActivation.description')}</p>
|
||||
<button
|
||||
onClick={() => props.onComplete(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE)}
|
||||
class="mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-border px-4 py-2.5 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
{t('setup.completion.proActivation.action')}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
aria-label={t('setup.completion.nextStep.ariaLabel')}
|
||||
class="bg-surface rounded-md border border-border p-5 sm:p-6 text-left mb-6"
|
||||
|
||||
@@ -18,6 +18,7 @@ export const SetupCompletionPreview: Component = () => {
|
||||
return (
|
||||
<SetupCompletionPanel
|
||||
connectedResourcesOverride={previewScenario().resources}
|
||||
proActivationOverride={previewScenario().proActivation}
|
||||
state={previewWizardState}
|
||||
onComplete={(nextPath) => {
|
||||
if (nextPath) {
|
||||
|
||||
+22
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => <SetupCompletionPreview />);
|
||||
|
||||
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(() => <SetupCompletionPreview />);
|
||||
|
||||
expect(apiFetchJSONMock).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('Activate Pulse Pro')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+186
@@ -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(() => (
|
||||
<SetupCompletionPanel
|
||||
state={baseState}
|
||||
onComplete={onComplete}
|
||||
connectedResourcesOverride={[]}
|
||||
proActivationOverride={true}
|
||||
/>
|
||||
));
|
||||
|
||||
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(() => (
|
||||
<SetupCompletionPanel
|
||||
state={baseState}
|
||||
onComplete={vi.fn()}
|
||||
connectedResourcesOverride={[]}
|
||||
proActivationOverride={false}
|
||||
/>
|
||||
));
|
||||
|
||||
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(() => <SetupCompletionPanel state={baseState} onComplete={vi.fn()} />);
|
||||
|
||||
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(() => <SetupCompletionPanel state={baseState} onComplete={vi.fn()} />);
|
||||
|
||||
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(() => <SetupCompletionPanel state={baseState} onComplete={vi.fn()} />);
|
||||
|
||||
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(() => <SetupCompletionPanel state={baseState} onComplete={vi.fn()} />);
|
||||
|
||||
await waitForLicenseProbe();
|
||||
expect(screen.queryByText('Activate Pulse Pro')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user