Keep self-hosted paid surfaces opt-in

This commit is contained in:
rcourtman
2026-04-25 13:45:31 +01:00
parent 1149eaa16c
commit 48e3db142e
62 changed files with 1714 additions and 945 deletions
@@ -0,0 +1,50 @@
# Self-Hosted Paid Services Opt-In Surface Record
- Date: `2026-04-25`
- Assertion: `RA5`
- Lane: `L2`
- Result: `pass`
## Decision
Ordinary self-hosted Pulse v6 users must not be presented with paid-service
prompts, Pro trial CTAs, plan upsells, or paid-only navigation by default.
Commercial surfaces remain available only when the user deliberately reaches
for them through an explicit handoff/direct route, is running in hosted mode, or
already has paid entitlement/activation/recovery state.
This supersedes the earlier trial-first self-hosted monetization posture for
the normal v6 GA self-hosted app. Trial and checkout plumbing may remain for
support-only or externally initiated flows, but it is not a default in-app user
journey.
## Product Boundary
- Core self-hosted monitoring stays free and uncapped.
- Default Community/self-hosted UI stays quiet about paid services.
- Paid-only feature navigation is hidden unless the instance already has the
corresponding entitlement or recovery context.
- The billing route remains reachable directly for activation, recovery, and
explicit commercial handoff.
- Hosted mode can still opt in to promotional/commercial prompts through the
presentation policy contract.
## Proof
- `npm --prefix frontend-modern test -- --run src/stores/__tests__/sessionPresentationPolicy.test.ts src/stores/__tests__/license.test.ts src/components/Settings/__tests__/ProLicensePanel.test.tsx src/components/Settings/__tests__/settingsNavigation.integration.test.tsx src/components/Settings/__tests__/RBACPaywallPanels.test.tsx src/components/Settings/__tests__/AISettings.test.tsx src/pages/__tests__/AIIntelligence.test.tsx src/components/shared/__tests__/HistoryChart.test.tsx src/components/shared/__tests__/TrialBanner.test.tsx src/components/Settings/__tests__/useReportingPanelState.test.ts src/components/Settings/__tests__/ReportingPanel.test.tsx src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx src/components/Settings/__tests__/AgentProfilesPanel.test.tsx src/features/patrol/__tests__/patrolCommercialBoundary.test.ts`
- Result: pass, 14 files and 195 tests.
- `npm --prefix frontend-modern run type-check`
- Result: pass.
- `GOTOOLCHAIN=go1.25.9+auto go test ./internal/api -run 'TestContract_SecurityStatusPresentationPolicyDefaultsHideUpgradeOutsideHosted|TestContract_SecurityStatusIncludesSessionCapabilitiesDemoMode|TestSecurityStatusIncludesDemoModeSessionCapabilities|Test.*Trial|Test.*HostedEntitlementRefresh|TestContract_SelfHostedCommunityEntitlementsJSONSnapshot|TestContract_SelfHostedCommunityRuntimeCapabilitiesJSONSnapshot' -count=1`
- Result: pass.
- `GOTOOLCHAIN=go1.25.9+auto go test ./internal/cloudcp ./internal/cloudcp/stripe -count=1`
- Result: pass.
- `PULSE_E2E_USE_LOCAL_BACKEND=1 npm --prefix tests/integration test -- tests/58-self-hosted-trial-rate-limit-ui.spec.ts --project=chromium`
- Result: pass in Chromium against a managed local backend.
## Known Unrelated State
- Repository-wide frontend format check still reports pre-existing warnings
outside this slice.
- `frontendResourceTypeBoundaries.test.ts` still has a pre-existing Recovery
boundary failure unrelated to the paid-services visibility policy.
+68 -4
View File
@@ -307,7 +307,7 @@
},
{
"id": "RA5",
"summary": "Non-paid users do not see paid-only navigation or pages unless the surface is explicitly promotional.",
"summary": "Ordinary self-hosted v6 users do not see paid service prompts, trial CTAs, plan upsells, or paid-only navigation by default; commercial surfaces are reachable only through explicit handoff, direct route, hosted mode, or active entitlement/recovery state.",
"kind": "invariant",
"blocking_level": "rc-ready",
"proof_type": "hybrid",
@@ -334,13 +334,47 @@
"test",
"--",
"--run",
"src/stores/__tests__/sessionPresentationPolicy.test.ts",
"src/stores/__tests__/license.test.ts",
"src/components/Settings/__tests__/ProLicensePanel.test.tsx",
"src/components/Settings/__tests__/RBACPaywallPanels.test.tsx",
"src/components/Settings/__tests__/settingsNavigation.integration.test.tsx",
"src/pages/__tests__/AIIntelligence.test.tsx"
"src/components/Settings/__tests__/AISettings.test.tsx",
"src/components/Settings/__tests__/ReportingPanel.test.tsx",
"src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx",
"src/components/Settings/__tests__/AgentProfilesPanel.test.tsx",
"src/components/shared/__tests__/HistoryChart.test.tsx",
"src/components/shared/__tests__/TrialBanner.test.tsx",
"src/pages/__tests__/AIIntelligence.test.tsx",
"src/features/patrol/__tests__/patrolCommercialBoundary.test.ts"
]
},
{
"id": "self-hosted-paid-prompt-browser-visibility",
"run": [
"env",
"PULSE_E2E_USE_LOCAL_BACKEND=1",
"npm",
"--prefix",
"tests/integration",
"test",
"--",
"tests/58-self-hosted-trial-rate-limit-ui.spec.ts",
"--project=chromium"
]
}
],
"evidence": [
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/self-hosted-paid-services-opt-in-surface-2026-04-25.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx",
@@ -351,10 +385,35 @@
"path": "frontend-modern/src/components/Settings/__tests__/settingsNavigation.integration.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/components/Settings/useProLicensePanelState.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/stores/__tests__/sessionPresentationPolicy.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/stores/licenseCommercial.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/stores/sessionPresentationPolicy.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "tests/integration/tests/58-self-hosted-trial-rate-limit-ui.spec.ts",
"kind": "file"
}
]
},
@@ -2540,7 +2599,7 @@
"status": "partial",
"completion": {
"state": "bounded-residual",
"summary": "Commercial conversion surfaces are now coherent through the preview and app-side rehearsal path, while the production public flip and release-day rollout execution remain a separate promotion-time follow-up.",
"summary": "Commercial readiness is coherent for explicit preview, checkout handoff, paid-user activation/recovery, and hosted-mode paths, while default self-hosted app surfaces stay free-first and non-promotional; the production public flip and release-day rollout execution remain a separate promotion-time follow-up.",
"tracking": [
{
"kind": "lane-followup",
@@ -2566,6 +2625,11 @@
"path": "docs/release-control/v6/internal/records/self-hosted-commercial-ga-coherence-2026-04-20.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/self-hosted-paid-services-opt-in-surface-2026-04-25.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/subsystems/cloud-paid.md",
@@ -4156,7 +4220,7 @@
},
{
"id": "commercial-ga-promotion-package",
"summary": "Track the production public flip and release-day execution work for the self-hosted commercial package now that preview, in-app entry, checkout handoff, and Pulse Account coherence are already proved.",
"summary": "Track the production public flip and release-day execution work for the self-hosted commercial package now that preview, external checkout handoff, paid-user recovery paths, and Pulse Account coherence are proved; default self-hosted app surfaces must stay free-first and non-promotional.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-04-20",
@@ -686,6 +686,13 @@ capacity posture from public-preview runtime capabilities. Lifecycle-adjacent
install or reporting surfaces may still depend on demo-safe capability flags,
but they must not expect `monitored_system_capacity`, admission-freeze copy,
or observed plan overage posture to survive the public-demo boundary.
The same presentation-policy split now governs paid lifecycle extensions in
ordinary self-hosted v6 installs. Agent profile management may remain an
entitled lifecycle surface, but default Infrastructure navigation must not
advertise agent-profile upgrades, trial prompts, or paid helper links while
`presentationPolicy.hideUpgrade` is true; it should stay on the free source
manager unless an explicit entitlement or recovery context makes the paid
lifecycle surface relevant.
That same demo-hidden API boundary also keeps runtime-admin operations out of
public lifecycle flows: `/api/diagnostics`,
`/api/diagnostics/docker/prepare-token`, and `/api/logs/*` must return `404`
@@ -835,6 +835,11 @@ as the canonical runtime contract for `demoMode`, `readOnly`,
must therefore defer their first read until that policy has resolved, so
public demos fail closed without probing hidden commercial routes during
bootstrap.
For ordinary self-hosted v6 installs, that same security-status contract owns
the free-first commercial posture: `hideUpgrade` defaults to true outside
hosted mode, and API consumers must treat it as a prompt-suppression contract
for upgrade links, trial CTAs, plan upsells, and paid-only navigation rather
than as a billing entitlement change.
That same contract split also makes the licensing boundary explicit:
`/api/license/runtime-capabilities` is the public runtime feature contract,
`/api/license/commercial-posture` is the non-billing upgrade/trial posture
@@ -461,6 +461,13 @@ Community limit enforcement.
from the local conversion store instead of exporting those event rows to
the commercial service or reconstructing them from hosted checkout
telemetry.
14. Keep ordinary self-hosted v6 commercial prompts opt-in. Cloud-paid runtime
may keep checkout, activation, recovery, and support-only trial plumbing
available for explicit handoffs and entitled installs, but default
self-hosted browser surfaces must honor `presentationPolicy.hideUpgrade`
and suppress Relay/Pro plan comparison, Pro trial CTAs, paid-only settings
navigation, and feature upsells unless hosted mode, direct intent,
activation/recovery state, or active entitlement makes them relevant.
## Current State
@@ -1997,6 +2004,14 @@ public demos and other browser-first login surfaces do not emit avoidable
`401` traffic before a session exists. Once the local login flow has written
its bootstrap hint or the operator is landing on a protected route, the
canonical state probe still owns authenticated runtime detection.
That same presentation-policy contract now owns the default self-hosted v6
commercial posture. Outside hosted mode, ordinary self-hosted installs must
fail closed on in-app upgrade prompts: Relay/Pro plan comparison, Pro trial
CTAs, paid-only settings navigation, and history/feature upsells may render
only for explicit handoff/direct routes, activation or recovery state, hosted
mode, or an already entitled install. Trial checkout plumbing may remain for
support-only or externally initiated flows, but it is not a normal GA
self-hosted app journey.
That public-demo commercial boundary also owns monitored-system preview
unavailability wording. Browser presentation may keep the unavailable reason
nullable until the formatting edge, but it must normalize the message through
@@ -748,6 +748,14 @@ work extends shared components instead of creating new local variants.
present in the shared app shell, top-level tab switches must reuse that
state boundary instead of flashing a transient infrastructure page
takeover between tabs.
35. Keep self-hosted paid-service prompts opt-in at the shared shell layer.
`settingsNavCatalog.ts`, `settingsNavVisibility.ts`, shared upgrade link
primitives, trial banners, and history-lock overlays must honor
`presentationPolicy.hideUpgrade` by hiding paid prompts by default on
ordinary self-hosted installs. Direct activation/recovery routes may
render their owned content, but sidebar discovery, trial CTAs, plan upsells,
and feature upgrade links must require hosted mode, explicit handoff, or
active entitlement.
## Current State
@@ -2527,6 +2535,14 @@ response-header inference, hostname heuristics, or per-banner demo branching;
the runtime bootstrap, shared session-capability store, and shared banner
hooks stay on one canonical owner so suppression stays coherent across
customer-facing surfaces.
That same session-presentation boundary owns the non-promotional self-hosted
v6 app posture. Settings navigation, shared upgrade links, trial banners,
history-lock overlays, and paid-feature gate primitives must honor resolved
`presentationPolicy.hideUpgrade` by hiding prompts by default on ordinary
self-hosted installs. Direct activation/recovery routes may still render their
owned content, but sidebar discovery, trial CTAs, plan upsells, and feature
upgrade links must not appear unless an explicit handoff, hosted-mode policy,
or active entitlement says they should.
That same shared app-shell boundary now also owns assistant bootstrap silence
on non-AI routes. `frontend-modern/src/useAppRuntimeState.ts`,
`frontend-modern/src/App.tsx`,
@@ -245,3 +245,8 @@ setup completion surface allowed to bootstrap posture outside that shell.
RBAC paywall state should also consume intent-level selectors such as
`canOfferCommercialTrial()` from `frontend-modern/src/stores/licenseCommercial.ts`
instead of reading raw posture fields locally.
Under the free-first self-hosted v6 policy, those RBAC gates must also honor
`presentationPolicy.hideUpgrade`: ordinary self-hosted users should not see
RBAC trial CTAs, upgrade copy, or paid-only Roles/Users/Audit navigation by
default. Direct routes may recover to the canonical free settings surface, and
entitled or hosted-mode installs may still render the governed RBAC surfaces.
@@ -196,6 +196,12 @@ but they must not trigger their own mount-time `loadCommercialPosture()`
reads. Authenticated Patrol shells inherit that bootstrap from
`frontend-modern/src/useAppRuntimeState.ts`, so Patrol-specific hooks do not
quietly retake ownership of commercial fetch timing.
Under ordinary self-hosted v6, Patrol commercial affordances must also honor
the shared `presentationPolicy.hideUpgrade` contract. A free self-hosted
install may show Patrol runtime availability and configuration gaps, but it
must not show Pro trial CTAs, upgrade links, or paid helper copy unless hosted
mode, an explicit commercial handoff, or an active entitlement makes those
actions relevant.
That degraded empty-state copy must also interpret the finding state rather
than simply replaying the primary assessment sentence verbatim: when coverage
is incomplete, the findings panel should tell the operator that Patrol has not
@@ -141,6 +141,12 @@ querying, and the operator-facing storage health presentation layer.
labels as `/api/resources` so storage and recovery do not momentarily switch
between legacy and canonical infrastructure identities within one session.
That same adjacent API boundary now also owns SSO outbound discovery and metadata fetch trust: storage- and recovery-adjacent surfaces may share `internal/api/sso_outbound.go`, `internal/api/saml_service.go`, and `internal/api/oidc_service.go`, but they must not fork separate metadata/discovery HTTP clients, redirect policies, or credential-file read rules when they depend on shared backend auth helpers.
That same adjacent security-status boundary now also owns paid prompt
suppression for storage/recovery-adjacent primitives. History and recovery
copy may describe unavailable local capability, but ordinary self-hosted v6
installs must not reinterpret `presentationPolicy.hideUpgrade` as a license
upsell opportunity or surface paid history/recovery upgrade prompts by
default.
5. Route canonical storage/recovery resource selection through `frontend-modern/src/hooks/useUnifiedResources.ts` and the owning `unified-resources` contract
That shared hook now also projects resource `clusterId` through the shared cluster-name helper, so storage and recovery links keep the same cluster-context label as other unified-resource consumers instead of rebuilding a local fallback chain.
That shared hook plus the adjacent websocket/store adapter path must keep
@@ -38,7 +38,12 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
onClick={() => state.setShowDiscoverySettings(!state.showDiscoverySettings())}
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-4 h-4 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -66,7 +71,12 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<Show when={state.showDiscoverySettings()}>
@@ -74,10 +84,7 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
<div class="flex items-center justify-between gap-2">
<label class="text-xs font-medium text-muted flex items-center gap-1.5">
Enable workload discovery
<HelpIcon
inline={getAISettingsWorkloadDiscoveryHelpContent()}
size="xs"
/>
<HelpIcon inline={getAISettingsWorkloadDiscoveryHelpContent()} size="xs" />
</label>
<Toggle
checked={state.form.discoveryEnabled}
@@ -89,7 +96,9 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
<Show when={state.form.discoveryEnabled}>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<label class="text-xs font-medium text-muted w-32 flex-shrink-0">Scan Interval</label>
<label class="text-xs font-medium text-muted w-32 flex-shrink-0">
Scan Interval
</label>
<select
class="flex-1 px-2 py-1 text-sm border border-border rounded bg-surface"
value={state.form.discoveryIntervalHours}
@@ -114,15 +123,18 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
</div>
</Show>
<p class="text-[10px] text-muted">
{getAISettingsWorkloadDiscoverySummary().text}
</p>
<p class="text-[10px] text-muted">{getAISettingsWorkloadDiscoverySummary().text}</p>
</div>
</Show>
</div>
<div class="flex items-center gap-3 p-3 rounded-md border border-border bg-surface-alt">
<svg class="w-4 h-4 text-muted flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-4 h-4 text-muted flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -145,7 +157,9 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
/>
</div>
<Show when={parseFloat(state.form.costBudgetUSD30d) > 0}>
<span class="text-xs"> ${(parseFloat(state.form.costBudgetUSD30d) / 30).toFixed(2)}/day</span>
<span class="text-xs">
${(parseFloat(state.form.costBudgetUSD30d) / 30).toFixed(2)}/day
</span>
</Show>
<Show when={!state.form.costBudgetUSD30d || parseFloat(state.form.costBudgetUSD30d) === 0}>
<span class="text-[10px] text-muted">Set a budget to receive usage alerts</span>
@@ -153,7 +167,12 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
</div>
<div class="flex items-center gap-3 p-3 rounded-md border border-border bg-surface-alt">
<svg class="w-4 h-4 text-muted flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-4 h-4 text-muted flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -189,9 +208,16 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
Increase for slower Ollama hardware (default: 300s / 5 min)
</p>
<div class={`space-y-3 p-4 rounded-md border ${getAIControlLevelPanelClass(state.form.controlLevel)}`}>
<div
class={`space-y-3 p-4 rounded-md border ${getAIControlLevelPanelClass(state.form.controlLevel)}`}
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-4 h-4 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -203,7 +229,9 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
{AI_SETTINGS_ASSISTANT_PERMISSIONS_TITLE}
</span>
<Show when={state.form.controlLevel !== 'read_only'}>
<span class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${getAIControlLevelBadgeClass(state.form.controlLevel)}`}>
<span
class={`px-1.5 py-0.5 text-[10px] font-medium rounded ${getAIControlLevelBadgeClass(state.form.controlLevel)}`}
>
{state.form.controlLevel}
</span>
</Show>
@@ -218,8 +246,12 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
disabled={state.saving()}
>
<option value="read_only">Read Only - Pulse Assistant can only observe</option>
<option value="controlled">Controlled - Pulse Assistant executes with your approval</option>
<option value="autonomous">Autonomous - Pulse Assistant executes without approval (Pro)</option>
<option value="controlled">
Controlled - Pulse Assistant executes with your approval
</option>
<option value="autonomous">
Autonomous - Pulse Assistant executes without approval (Pro)
</option>
</select>
</div>
<p class="text-[10px] text-muted ml-[7.5rem]">
@@ -240,7 +272,13 @@ export const AIRuntimeControlsSection: Component<AIRuntimeControlsSectionProps>
.
</div>
</Show>
<Show when={state.form.controlLevel === 'autonomous' && state.autoFixLocked()}>
<Show
when={
state.form.controlLevel === 'autonomous' &&
state.autoFixLocked() &&
state.showUpgradePrompts()
}
>
<p class="text-xs text-muted">
<UpgradeLink
class="text-blue-600 dark:text-blue-400 font-medium hover:underline"
@@ -54,6 +54,7 @@ export const AgentProfilesPanel: Component = () => {
setFormName,
setShowModal,
setShowSuggestModal,
showUpgradePrompts,
showModal,
showSuggestModal,
startingTrial,
@@ -83,43 +84,45 @@ export const AgentProfilesPanel: Component = () => {
<Show
when={hasAgentProfiles()}
fallback={
<Card padding="lg" class="space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-md bg-amber-100 dark:bg-amber-900">
<Crown class="w-5 h-5 text-amber-600 dark:text-amber-400" />
<Show when={showUpgradePrompts()}>
<Card padding="lg" class="space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-10 h-10 rounded-md bg-amber-100 dark:bg-amber-900">
<Crown class="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 class="text-base font-semibold text-base-content">Agent Profiles</h3>
<p class="text-sm text-muted">Pro feature</p>
</div>
</div>
<div>
<h3 class="text-base font-semibold text-base-content">Agent Profiles</h3>
<p class="text-sm text-muted">Pro feature</p>
</div>
</div>
<p class="text-sm text-muted">
Create reusable configuration profiles for your agents. Manage settings like Docker
monitoring, logging levels, and reporting intervals from a central location.
</p>
<div class="flex flex-wrap items-center gap-3">
<UpgradeLink
destination={getUpgradeActionDestination('agent_profiles')}
class={getUpgradeActionButtonClass({ tone: 'warning', mobileFullWidth: false })}
onClick={() =>
trackUpgradeClicked('settings_agent_profiles_panel', 'agent_profiles')
}
>
<Crown class="w-4 h-4" />
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
<Show when={canStartTrial()}>
<button
type="button"
onClick={handleStartTrial}
disabled={startingTrial()}
class={UPGRADE_TRIAL_LINK_CLASS}
<p class="text-sm text-muted">
Create reusable configuration profiles for your agents. Manage settings like Docker
monitoring, logging levels, and reporting intervals from a central location.
</p>
<div class="flex flex-wrap items-center gap-3">
<UpgradeLink
destination={getUpgradeActionDestination('agent_profiles')}
class={getUpgradeActionButtonClass({ tone: 'warning', mobileFullWidth: false })}
onClick={() =>
trackUpgradeClicked('settings_agent_profiles_panel', 'agent_profiles')
}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
</Card>
<Crown class="w-4 h-4" />
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
<Show when={canStartTrial()}>
<button
type="button"
onClick={handleStartTrial}
disabled={startingTrial()}
class={UPGRADE_TRIAL_LINK_CLASS}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
</Card>
</Show>
}
>
<div class="space-y-6">
@@ -73,6 +73,7 @@ export default function AuditLogPanel() {
setUserFilter,
setVerificationFilter,
showUpgradePaywall,
showUpgradePrompts,
startingTrial,
submitPageInput,
successFilter,
@@ -157,14 +158,15 @@ export default function AuditLogPanel() {
}
>
<Show when={showUpgradePaywall()}>
<div class="p-6 bg-surface-alt border border-border rounded-md">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-base-content">Audit Logging</h3>
<p class="text-sm text-muted mt-1">
Persistent, searchable audit logs with cryptographic signature verification.
</p>
</div>
<div class="p-6 bg-surface-alt border border-border rounded-md">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-base-content">Audit Logging</h3>
<p class="text-sm text-muted mt-1">
Persistent, searchable audit logs with cryptographic signature verification.
</p>
</div>
<Show when={showUpgradePrompts()}>
<div class="flex flex-col items-center gap-2">
<UpgradeLink
destination={upgradeDestination()}
@@ -184,388 +186,389 @@ export default function AuditLogPanel() {
</button>
</Show>
</div>
</div>
</Show>
</div>
</div>
</Show>
<Show when={isPersistent()}>
<div class="flex flex-wrap gap-3 p-4 bg-surface-alt rounded-md">
<div class="flex items-center gap-2">
<Filter class="w-4 h-4 text-slate-400" />
<span class="text-sm font-medium text-base-content">Filters:</span>
</div>
<select
value={eventFilter()}
onChange={(e) => setEventFilter(e.currentTarget.value)}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content"
>
<option value="">All Events</option>
<option value="login">Login</option>
<option value="logout">Logout</option>
<option value="config_change">Config Change</option>
<option value="startup">Startup</option>
</select>
<input
type="text"
placeholder="Filter by user..."
value={userFilter()}
onInput={(e) => setUserFilter(e.currentTarget.value)}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content placeholder-gray-400"
/>
<select
value={successFilter()}
onChange={(e) => setSuccessFilter(e.currentTarget.value)}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content"
>
<option value="all">All</option>
<option value="success">Success Only</option>
<option value="failed">Failed Only</option>
</select>
<select
value={verificationFilter()}
onChange={(e) => setVerificationFilter(e.currentTarget.value)}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content"
>
<option value="all">All Verification</option>
<option value="needs">Needs Verification</option>
<option value="verified">Verified</option>
<option value="failed">Failed/Error</option>
</select>
<select
value={String(pageSize())}
onChange={(e) => setPageSize(Number(e.currentTarget.value))}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content"
>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
<option value="200">200 / page</option>
</select>
<button
onClick={applyFilters}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
>
Apply
</button>
<button
onClick={clearFilters}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover"
>
Clear{activeFilterCount() > 0 ? ` (${activeFilterCount()})` : ''}
</button>
<div class="flex flex-wrap gap-3 p-4 bg-surface-alt rounded-md">
<div class="flex items-center gap-2">
<Filter class="w-4 h-4 text-slate-400" />
<span class="text-sm font-medium text-base-content">Filters:</span>
</div>
<Show when={activeFilterCount() > 0}>
<div class="mt-2 flex flex-wrap gap-2">
<For each={activeFilterChips()}>
{(chip) => (
<button
type="button"
onClick={() => clearFilterChip(chip.key)}
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-surface-alt text-base-content hover:bg-surface-hover"
title="Click to clear filter"
aria-label={`Clear ${chip.label}`}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip('Click to clear filter', rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
clearFilterChip(chip.key);
}}
>
{chip.label}
<X class="w-3 h-3 text-muted" />
</button>
)}
</For>
</div>
</Show>
</Show>
{/* Verification Preferences */}
<Show when={isPersistent()}>
<div class="flex flex-col gap-4 px-1 lg:flex-row lg:items-center lg:justify-between">
<Toggle
label="Auto-verify signatures"
description="Verify signatures after loading audit events."
checked={autoVerifyEnabled()}
onChange={(e) => setAutoVerifyEnabled(e.currentTarget.checked)}
/>
<div class="flex flex-col gap-2 text-xs text-muted sm:flex-row sm:flex-wrap sm:items-center">
<label class="flex items-center gap-2">
<span>Auto-verify limit</span>
<input
type="number"
min="0"
value={autoVerifyLimit()}
disabled={!autoVerifyEnabled()}
onInput={(e) => {
const raw = Number(e.currentTarget.value);
if (!Number.isFinite(raw)) {
setAutoVerifyLimit(0);
return;
}
const clamped = Math.max(0, Math.min(500, Math.floor(raw)));
setAutoVerifyLimit(clamped);
<select
value={eventFilter()}
onChange={(e) => setEventFilter(e.currentTarget.value)}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content"
>
<option value="">All Events</option>
<option value="login">Login</option>
<option value="logout">Logout</option>
<option value="config_change">Config Change</option>
<option value="startup">Startup</option>
</select>
<input
type="text"
placeholder="Filter by user..."
value={userFilter()}
onInput={(e) => setUserFilter(e.currentTarget.value)}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content placeholder-gray-400"
/>
<select
value={successFilter()}
onChange={(e) => setSuccessFilter(e.currentTarget.value)}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content"
>
<option value="all">All</option>
<option value="success">Success Only</option>
<option value="failed">Failed Only</option>
</select>
<select
value={verificationFilter()}
onChange={(e) => setVerificationFilter(e.currentTarget.value)}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content"
>
<option value="all">All Verification</option>
<option value="needs">Needs Verification</option>
<option value="verified">Verified</option>
<option value="failed">Failed/Error</option>
</select>
<select
value={String(pageSize())}
onChange={(e) => setPageSize(Number(e.currentTarget.value))}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm border border-border rounded-md bg-surface text-base-content"
>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
<option value="200">200 / page</option>
</select>
<button
onClick={applyFilters}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
>
Apply
</button>
<button
onClick={clearFilters}
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover"
>
Clear{activeFilterCount() > 0 ? ` (${activeFilterCount()})` : ''}
</button>
</div>
<Show when={activeFilterCount() > 0}>
<div class="mt-2 flex flex-wrap gap-2">
<For each={activeFilterChips()}>
{(chip) => (
<button
type="button"
onClick={() => clearFilterChip(chip.key)}
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-surface-alt text-base-content hover:bg-surface-hover"
title="Click to clear filter"
aria-label={`Clear ${chip.label}`}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip('Click to clear filter', rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
class="min-h-10 sm:min-h-10 w-24 rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
/>
</label>
<span class="text-muted">0 disables, max 500</span>
<span
class="inline-flex items-center cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(
'Large verification batches can add load. Use a smaller limit on busy systems.',
rect.left + rect.width / 2,
rect.top,
{ align: 'center', direction: 'up', maxWidth: 260 },
);
onMouseLeave={() => hideTooltip()}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
clearFilterChip(chip.key);
}}
>
{chip.label}
<X class="w-3 h-3 text-muted" />
</button>
)}
</For>
</div>
</Show>
</Show>
{/* Verification Preferences */}
<Show when={isPersistent()}>
<div class="flex flex-col gap-4 px-1 lg:flex-row lg:items-center lg:justify-between">
<Toggle
label="Auto-verify signatures"
description="Verify signatures after loading audit events."
checked={autoVerifyEnabled()}
onChange={(e) => setAutoVerifyEnabled(e.currentTarget.checked)}
/>
<div class="flex flex-col gap-2 text-xs text-muted sm:flex-row sm:flex-wrap sm:items-center">
<label class="flex items-center gap-2">
<span>Auto-verify limit</span>
<input
type="number"
min="0"
value={autoVerifyLimit()}
disabled={!autoVerifyEnabled()}
onInput={(e) => {
const raw = Number(e.currentTarget.value);
if (!Number.isFinite(raw)) {
setAutoVerifyLimit(0);
return;
}
const clamped = Math.max(0, Math.min(500, Math.floor(raw)));
setAutoVerifyLimit(clamped);
}}
onMouseLeave={() => hideTooltip()}
>
<Info class="w-3 h-3" />
</span>
<button
onClick={resetPreferences}
class="w-full sm:w-auto sm:ml-2 min-h-10 sm:min-h-10 px-3 py-2 text-sm font-medium text-muted border border-border rounded-md hover:bg-surface-hover"
>
Reset preferences
</button>
</div>
</div>
</Show>
<Show when={error()}>
<div class="p-4 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-md text-red-700 dark:text-red-300">
{error()}
</div>
</Show>
<Show when={loading()}>
<div class="flex items-center justify-center py-12">
<RefreshCw class="w-8 h-8 text-blue-500 animate-spin" />
<span class="sr-only">{getAuditLogLoadingState().text}</span>
</div>
</Show>
<Show when={!loading() && isPersistent() && filteredEvents().length > 0}>
<div class="flex flex-wrap items-center gap-3 text-xs text-muted">
<span>Total: {totalEvents()}</span>
<span>Signed: {verificationSummary().signed}</span>
<span>Verified: {verificationSummary().verified}</span>
<span>Failed: {verificationSummary().failed}</span>
<span>Errors: {verificationSummary().error}</span>
<span>Unavailable: {verificationSummary().unavailable}</span>
<span>Unchecked: {verificationSummary().unchecked}</span>
class="min-h-10 sm:min-h-10 w-24 rounded-md border border-border bg-surface px-2.5 py-2 text-sm text-base-content"
/>
</label>
<span class="text-muted">0 disables, max 500</span>
<span
class="inline-flex items-center gap-1 text-muted cursor-help"
class="inline-flex items-center cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const content = [
'Unsigned: no signature stored for this event',
'Unavailable: verification not supported by the logger',
'Not checked: signature exists but has not been verified',
'Failed: signature mismatch or tampering detected',
'Error: verification request failed',
].join('\n');
showTooltip(content, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
maxWidth: 320,
});
showTooltip(
'Large verification batches can add load. Use a smaller limit on busy systems.',
rect.left + rect.width / 2,
rect.top,
{ align: 'center', direction: 'up', maxWidth: 260 },
);
}}
onMouseLeave={() => hideTooltip()}
>
<Info class="w-3 h-3" />
Legend
</span>
<span class="w-full sm:w-auto sm:ml-auto text-muted">
{pageRangeText()} Page {pageNumber()} of {totalPages()}
</span>
<button
onClick={resetPreferences}
class="w-full sm:w-auto sm:ml-2 min-h-10 sm:min-h-10 px-3 py-2 text-sm font-medium text-muted border border-border rounded-md hover:bg-surface-hover"
>
Reset preferences
</button>
</div>
<div class="mt-4">
<PulseDataGrid
data={filteredEvents()}
columns={[
{
key: 'success',
label: 'Status',
width: '64px',
align: 'center',
render: (event) => {
const presentation = getAuditEventStatusPresentation(event.success);
const Icon = presentation.icon;
return <Icon class={presentation.className} />;
},
},
{
key: 'timestamp',
label: 'Timestamp',
render: (event) => (
<span class="text-muted">{formatTimestamp(event.timestamp)}</span>
),
},
{
key: 'event',
label: 'Event',
render: (event) => (
<span
class={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getAuditEventTypeBadgeClass(event.event)}`}
>
{event.event}
</span>
),
},
{
key: 'user',
label: 'User',
render: (event) => <span class="text-base-content">{event.user || '-'}</span>,
},
{
key: 'ip',
label: 'IP',
hiddenOnMobile: true,
render: (event) => (
<span class="text-muted font-mono text-xs">{event.ip || '-'}</span>
),
},
{
key: 'details',
label: 'Details',
hiddenOnMobile: true,
render: (event) => (
<span class="text-muted truncate max-w-xs">{event.details || '-'}</span>
),
},
{
key: 'verification',
label: 'Verification',
render: (event) => {
if (!event.signature) {
return <span class="text-xs">Unsigned</span>;
}
const state = verification()[event.id];
const isVerifying = verifying()[event.id];
const badge = getAuditVerificationBadgePresentation(state);
</div>
</Show>
return (
<div class="flex items-center gap-2">
<Show
when={isVerifying}
fallback={
<span
class={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badge.className}`}
>
{badge.label}
</span>
}
>
<span class="text-xs text-muted">Verifying</span>
</Show>
<button
onClick={() => void verifyEvent(event)}
disabled={isVerifying}
class={AUDIT_VERIFY_ROW_BUTTON_CLASS}
>
Verify
</button>
</div>
);
},
<Show when={error()}>
<div class="p-4 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-md text-red-700 dark:text-red-300">
{error()}
</div>
</Show>
<Show when={loading()}>
<div class="flex items-center justify-center py-12">
<RefreshCw class="w-8 h-8 text-blue-500 animate-spin" />
<span class="sr-only">{getAuditLogLoadingState().text}</span>
</div>
</Show>
<Show when={!loading() && isPersistent() && filteredEvents().length > 0}>
<div class="flex flex-wrap items-center gap-3 text-xs text-muted">
<span>Total: {totalEvents()}</span>
<span>Signed: {verificationSummary().signed}</span>
<span>Verified: {verificationSummary().verified}</span>
<span>Failed: {verificationSummary().failed}</span>
<span>Errors: {verificationSummary().error}</span>
<span>Unavailable: {verificationSummary().unavailable}</span>
<span>Unchecked: {verificationSummary().unchecked}</span>
<span
class="inline-flex items-center gap-1 text-muted cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const content = [
'Unsigned: no signature stored for this event',
'Unavailable: verification not supported by the logger',
'Not checked: signature exists but has not been verified',
'Failed: signature mismatch or tampering detected',
'Error: verification request failed',
].join('\n');
showTooltip(content, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
maxWidth: 320,
});
}}
onMouseLeave={() => hideTooltip()}
>
<Info class="w-3 h-3" />
Legend
</span>
<span class="w-full sm:w-auto sm:ml-auto text-muted">
{pageRangeText()} Page {pageNumber()} of {totalPages()}
</span>
</div>
<div class="mt-4">
<PulseDataGrid
data={filteredEvents()}
columns={[
{
key: 'success',
label: 'Status',
width: '64px',
align: 'center',
render: (event) => {
const presentation = getAuditEventStatusPresentation(event.success);
const Icon = presentation.icon;
return <Icon class={presentation.className} />;
},
]}
keyExtractor={(event) => event.id}
desktopMinWidth="900px"
/>
</div>
},
{
key: 'timestamp',
label: 'Timestamp',
render: (event) => (
<span class="text-muted">{formatTimestamp(event.timestamp)}</span>
),
},
{
key: 'event',
label: 'Event',
render: (event) => (
<span
class={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${getAuditEventTypeBadgeClass(event.event)}`}
>
{event.event}
</span>
),
},
{
key: 'user',
label: 'User',
render: (event) => <span class="text-base-content">{event.user || '-'}</span>,
},
{
key: 'ip',
label: 'IP',
hiddenOnMobile: true,
render: (event) => (
<span class="text-muted font-mono text-xs">{event.ip || '-'}</span>
),
},
{
key: 'details',
label: 'Details',
hiddenOnMobile: true,
render: (event) => (
<span class="text-muted truncate max-w-xs">{event.details || '-'}</span>
),
},
{
key: 'verification',
label: 'Verification',
render: (event) => {
if (!event.signature) {
return <span class="text-xs">Unsigned</span>;
}
const state = verification()[event.id];
const isVerifying = verifying()[event.id];
const badge = getAuditVerificationBadgePresentation(state);
return (
<div class="flex items-center gap-2">
<Show
when={isVerifying}
fallback={
<span
class={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badge.className}`}
>
{badge.label}
</span>
}
>
<span class="text-xs text-muted">Verifying</span>
</Show>
<button
onClick={() => void verifyEvent(event)}
disabled={isVerifying}
class={AUDIT_VERIFY_ROW_BUTTON_CLASS}
>
Verify
</button>
</div>
);
},
},
]}
keyExtractor={(event) => event.id}
desktopMinWidth="900px"
/>
</div>
</Show>
<Show when={!loading() && isPersistent() && filteredEvents().length === 0}>
<div class="text-center py-12 px-4 bg-surface-alt rounded-md border border-dashed border-border">
<div class="flex flex-col items-center max-w-sm mx-auto">
<Show
when={activeFilterCount() > 0}
fallback={<Shield class="w-12 h-12 text-slate-300 mb-4" />}
<div class="text-center py-12 px-4 bg-surface-alt rounded-md border border-dashed border-border">
<div class="flex flex-col items-center max-w-sm mx-auto">
<Show
when={activeFilterCount() > 0}
fallback={<Shield class="w-12 h-12 text-slate-300 mb-4" />}
>
<ShieldAlert class="w-12 h-12 text-blue-300 dark:text-blue-900 mb-4" />
</Show>
<h3 class="text-lg font-medium text-base-content">
{getAuditLogEmptyState(activeFilterCount()).title}
</h3>
<p class="mt-2 text-sm text-muted">
{getAuditLogEmptyState(activeFilterCount()).description}
</p>
<Show when={activeFilterCount() > 0}>
<button
onClick={clearFilters}
class="mt-6 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800 rounded-md hover:bg-blue-50 dark:hover:bg-blue-900"
>
<ShieldAlert class="w-12 h-12 text-blue-300 dark:text-blue-900 mb-4" />
</Show>
<h3 class="text-lg font-medium text-base-content">
{getAuditLogEmptyState(activeFilterCount()).title}
</h3>
<p class="mt-2 text-sm text-muted">
{getAuditLogEmptyState(activeFilterCount()).description}
</p>
<Show when={activeFilterCount() > 0}>
<button
onClick={clearFilters}
class="mt-6 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800 rounded-md hover:bg-blue-50 dark:hover:bg-blue-900"
>
Clear all filters
</button>
</Show>
</div>
Clear all filters
</button>
</Show>
</div>
</div>
</Show>
<Show when={!loading() && isPersistent()}>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<div class="flex items-center gap-2 text-xs text-muted">
<label class="flex items-center gap-2">
<span>Page</span>
<input
type="number"
min="1"
value={pageInput()}
onInput={(e) => setPageInput(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && submitPageInput()}
class="min-h-10 sm:min-h-10 w-20 rounded-md border border-border bg-surface px-2.5 text-sm text-base-content"
/>
</label>
<button
onClick={submitPageInput}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover"
>
Go
</button>
</div>
<div class="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center sm:justify-end">
<button
onClick={goToFirstPage}
disabled={pageNumber() === 1}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
>
First
</button>
<button
onClick={goToPreviousPage}
disabled={pageNumber() === 1}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
>
Previous
</button>
<button
onClick={goToNextPage}
disabled={!hasNextPage()}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
>
Next
</button>
<button
onClick={goToLastPage}
disabled={!hasNextPage()}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
>
Last
</button>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
<div class="flex items-center gap-2 text-xs text-muted">
<label class="flex items-center gap-2">
<span>Page</span>
<input
type="number"
min="1"
value={pageInput()}
onInput={(e) => setPageInput(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && submitPageInput()}
class="min-h-10 sm:min-h-10 w-20 rounded-md border border-border bg-surface px-2.5 text-sm text-base-content"
/>
</label>
<button
onClick={submitPageInput}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover"
>
Go
</button>
</div>
<div class="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center sm:justify-end">
<button
onClick={goToFirstPage}
disabled={pageNumber() === 1}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
>
First
</button>
<button
onClick={goToPreviousPage}
disabled={pageNumber() === 1}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
>
Previous
</button>
<button
onClick={goToNextPage}
disabled={!hasNextPage()}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
>
Next
</button>
<button
onClick={goToLastPage}
disabled={!hasNextPage()}
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
>
Last
</button>
</div>
</div>
</Show>
</SettingsPanel>
);
@@ -43,6 +43,7 @@ export const AuditWebhookPanel: Component<AuditWebhookPanelProps> = (props) => {
newUrl,
saving,
setNewUrl,
showUpgradePrompts,
startingTrial,
upgradeDestination,
webhookUrls,
@@ -61,31 +62,31 @@ export const AuditWebhookPanel: Component<AuditWebhookPanelProps> = (props) => {
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1 text-center sm:text-left">
<p class="font-semibold text-base-content">{featureGateCopy().title}</p>
<p class="mt-1 text-sm text-muted">
{featureGateCopy().body}
</p>
<p class="mt-1 text-sm text-muted">{featureGateCopy().body}</p>
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<UpgradeLink
destination={upgradeDestination()}
class={getUpgradeActionButtonClass()}
onClick={() =>
trackUpgradeClicked('settings_audit_webhook_panel', 'audit_logging')
}
>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
<Show when={canStartTrial()}>
<button
type="button"
onClick={handleStartTrial}
disabled={startingTrial()}
class={UPGRADE_TRIAL_LINK_CLASS}
<Show when={showUpgradePrompts()}>
<div class="flex flex-col sm:flex-row items-center gap-2">
<UpgradeLink
destination={upgradeDestination()}
class={getUpgradeActionButtonClass()}
onClick={() =>
trackUpgradeClicked('settings_audit_webhook_panel', 'audit_logging')
}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
<Show when={canStartTrial()}>
<button
type="button"
onClick={handleStartTrial}
disabled={startingTrial()}
class={UPGRADE_TRIAL_LINK_CLASS}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
</Show>
</div>
</Card>
</Show>
@@ -2,6 +2,7 @@ import { Component, Match, Show, Switch, createEffect, createMemo, createSignal
import { useLocation, useNavigate } from '@solidjs/router';
import X from 'lucide-solid/icons/x';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { copyToClipboard } from '@/utils/clipboard';
import { notificationStore } from '@/stores/notifications';
import { Dialog } from '@/components/shared/Dialog';
@@ -349,15 +350,17 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
const renderAgentAddSlot = () => (
<div class="space-y-4">
<div class="flex items-center justify-end">
<button
type="button"
onClick={() => setShowAgentProfiles((value) => !value)}
class="inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover"
>
{showAgentProfiles() ? 'Hide agent profiles' : 'Manage agent profiles'}
</button>
</div>
<Show when={runtimeCapabilitiesLoaded() && hasFeature('agent_profiles')}>
<div class="flex items-center justify-end">
<button
type="button"
onClick={() => setShowAgentProfiles((value) => !value)}
class="inline-flex items-center rounded-md border border-border px-2.5 py-1 text-xs font-medium text-base-content transition-colors hover:bg-surface-hover"
>
{showAgentProfiles() ? 'Hide agent profiles' : 'Manage agent profiles'}
</button>
</div>
</Show>
<Show when={showAgentProfiles()}>
<div class="rounded-xl border border-border bg-surface p-4 shadow-sm">
<div class="mb-4 space-y-1">
@@ -15,6 +15,7 @@ interface RBACFeatureGateSectionProps {
canStartTrial: boolean;
copy: RBACFeatureGateCopy;
paywallLocation: RBACFeatureGateLocation;
showUpgradePrompts: boolean;
startingTrial: boolean;
onStartTrial: () => void | Promise<void>;
}
@@ -26,25 +27,27 @@ export const RBACFeatureGateSection: Component<RBACFeatureGateSectionProps> = (p
<h4 class="text-base font-semibold text-base-content">{props.copy.title}</h4>
<p class="text-sm text-muted mt-1">{props.copy.body}</p>
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<UpgradeLink
destination={getUpgradeActionDestination('rbac')}
class={getUpgradeActionButtonClass()}
onClick={() => trackUpgradeClicked(props.paywallLocation, 'rbac')}
>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
<Show when={props.canStartTrial}>
<button
type="button"
onClick={props.onStartTrial}
disabled={props.startingTrial}
class={UPGRADE_TRIAL_LINK_CLASS}
<Show when={props.showUpgradePrompts}>
<div class="flex flex-col sm:flex-row items-center gap-2">
<UpgradeLink
destination={getUpgradeActionDestination('rbac')}
class={getUpgradeActionButtonClass()}
onClick={() => trackUpgradeClicked(props.paywallLocation, 'rbac')}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
<Show when={props.canStartTrial}>
<button
type="button"
onClick={props.onStartTrial}
disabled={props.startingTrial}
class={UPGRADE_TRIAL_LINK_CLASS}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
</Show>
</div>
</div>
);
@@ -35,10 +35,7 @@ export const RelaySettingsPanel: Component<RelaySettingsPanelProps> = (props) =>
// Pro feature gate
if (!state.relayEnabled()) {
return (
<SettingsPanel
title="Remote Access"
description={RELAY_SETTINGS_DESCRIPTION}
>
<SettingsPanel title="Remote Access" description={RELAY_SETTINGS_DESCRIPTION}>
<Show when={!state.loading()} fallback={<div class="text-sm ">Loading...</div>}>
<Card tone="info" padding="md">
<div class="flex flex-col sm:flex-row items-center gap-4">
@@ -49,25 +46,27 @@ export const RelaySettingsPanel: Component<RelaySettingsPanelProps> = (props) =>
<p class="text-sm text-muted mt-1">{RELAY_LICENSE_REQUIRED_MESSAGE}</p>
</div>
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<UpgradeLink
destination={state.upgradeDestination()}
class={RELAY_PRIMARY_LINK_CLASS}
onClick={() => trackUpgradeClicked('settings_relay_panel', 'relay')}
>
Upgrade
</UpgradeLink>
<Show when={state.canStartTrial()}>
<button
type="button"
onClick={state.handleStartTrial}
disabled={state.startingTrial()}
class={RELAY_INLINE_ACTION_CLASS}
<Show when={state.showUpgradePrompts()}>
<div class="flex flex-col sm:flex-row items-center gap-2">
<UpgradeLink
destination={state.upgradeDestination()}
class={RELAY_PRIMARY_LINK_CLASS}
onClick={() => trackUpgradeClicked('settings_relay_panel', 'relay')}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
Upgrade
</UpgradeLink>
<Show when={state.canStartTrial()}>
<button
type="button"
onClick={state.handleStartTrial}
disabled={state.startingTrial()}
class={RELAY_INLINE_ACTION_CLASS}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
</Show>
</div>
</Card>
</Show>
@@ -76,20 +75,13 @@ export const RelaySettingsPanel: Component<RelaySettingsPanelProps> = (props) =>
}
return (
<SettingsPanel
title="Remote Access"
description={RELAY_SETTINGS_DESCRIPTION}
>
<SettingsPanel title="Remote Access" description={RELAY_SETTINGS_DESCRIPTION}>
<Show
when={!state.loading()}
fallback={<div class="text-sm ">{getSettingsConfigurationLoadingState().text}</div>}
>
<Show when={!state.canManage()}>
<Card
tone="info"
padding="md"
class={RELAY_READONLY_NOTICE_CLASS}
>
<Card tone="info" padding="md" class={RELAY_READONLY_NOTICE_CLASS}>
Remote access settings are read-only for this account.
</Card>
</Show>
@@ -108,7 +100,9 @@ export const RelaySettingsPanel: Component<RelaySettingsPanelProps> = (props) =>
pulse={state.connectionPresentation().pulse}
/>
<div class="flex-1">
<p class="text-sm font-medium text-base-content">{state.connectionPresentation().label}</p>
<p class="text-sm font-medium text-base-content">
{state.connectionPresentation().label}
</p>
<Show when={state.status()?.instance_id}>
<p class="text-xs text-muted mt-0.5">Instance: {state.status()!.instance_id}</p>
</Show>
@@ -121,9 +115,7 @@ export const RelaySettingsPanel: Component<RelaySettingsPanelProps> = (props) =>
</div>
</div>
<Show when={state.status()?.last_error}>
<div class={RELAY_LAST_ERROR_CLASS}>
{state.status()!.last_error}
</div>
<div class={RELAY_LAST_ERROR_CLASS}>{state.status()!.last_error}</div>
</Show>
</Card>
@@ -64,6 +64,7 @@ export function ReportingPanel() {
setRange,
setSelectedResources,
setTitle,
showUpgradePrompts,
startingTrial,
title,
upgradeDestination,
@@ -78,8 +79,7 @@ export function ReportingPanel() {
const supportsCustomTitle = () => performanceReport()?.supportsCustomTitle ?? false;
const selectedRange = (): ReportingRangeValue => range() ?? performanceReport()!.defaultRange;
const selectedFormat = (): ReportingFormat => format() ?? performanceReport()!.defaultFormat;
const optionalFieldCount = () =>
Number(supportsMetricFilter()) + Number(supportsCustomTitle());
const optionalFieldCount = () => Number(supportsMetricFilter()) + Number(supportsCustomTitle());
const optionalFieldGridClass = () =>
optionalFieldCount() > 1 ? 'grid grid-cols-1 gap-6 md:grid-cols-2' : 'grid grid-cols-1 gap-6';
@@ -99,10 +99,7 @@ export function ReportingPanel() {
return (
<div class="space-y-6">
<Show when={reportingCatalogLoading() && !catalogReady()}>
<OperationsPanel
title="Reporting"
description="Loading reporting surfaces..."
>
<OperationsPanel title="Reporting" description="Loading reporting surfaces...">
<div class="p-4 sm:p-6">
<p class="text-sm text-muted">Loading reporting surfaces...</p>
</div>
@@ -146,25 +143,29 @@ export function ReportingPanel() {
)}
</Show>
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<UpgradeLink
destination={upgradeDestination()}
class={getUpgradeActionButtonClass()}
onClick={() => trackUpgradeClicked('settings_reporting_panel', reportingCatalog()!.id)}
>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
<Show when={canStartTrial()}>
<button
type="button"
onClick={handleStartTrial}
disabled={startingTrial()}
class={UPGRADE_TRIAL_LINK_CLASS}
<Show when={showUpgradePrompts()}>
<div class="flex flex-col sm:flex-row items-center gap-2">
<UpgradeLink
destination={upgradeDestination()}
class={getUpgradeActionButtonClass()}
onClick={() =>
trackUpgradeClicked('settings_reporting_panel', reportingCatalog()!.id)
}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
{UPGRADE_ACTION_LABEL}
</UpgradeLink>
<Show when={canStartTrial()}>
<button
type="button"
onClick={handleStartTrial}
disabled={startingTrial()}
class={UPGRADE_TRIAL_LINK_CLASS}
>
{UPGRADE_TRIAL_LABEL}
</button>
</Show>
</div>
</Show>
</div>
</div>
</OperationsPanel>
@@ -37,6 +37,7 @@ export const RolesPanel: Component = () => {
canStartTrial={state.featureGate.canStartTrial()}
copy={state.featureGate.featureGateCopy()}
paywallLocation="settings_roles_panel"
showUpgradePrompts={state.featureGate.showUpgradePrompts()}
startingTrial={state.featureGate.startingTrial()}
onStartTrial={state.featureGate.handleStartTrial}
/>
@@ -82,12 +82,13 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
fetchMetadataPreview,
getUpgradeActionDestination,
runtimeCapabilitiesLoaded,
showUpgradePrompts,
trackUpgradeClicked,
} = useSSOProvidersState(props);
return (
<div class="space-y-6">
<Show when={showSamlUpsell()}>
<Show when={showUpgradePrompts() && showSamlUpsell()}>
<Dialog
isOpen={true}
onClose={() => setShowSamlUpsell(false)}
@@ -147,7 +148,11 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
</Show>
{/* License banner */}
<Show when={runtimeCapabilitiesLoaded() && !hasAdvancedSSO() && !loading()}>
<Show
when={
showUpgradePrompts() && runtimeCapabilitiesLoaded() && !hasAdvancedSSO() && !loading()
}
>
<div class="p-4 bg-surface-alt border border-border rounded-md">
<div class="flex flex-col sm:flex-row items-center gap-4">
<div class="flex-1">
@@ -207,7 +212,7 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
{getSSOProviderAddButtonLabel('saml')}
</button>
</Show>
<Show when={runtimeCapabilitiesLoaded() && !hasAdvancedSSO()}>
<Show when={showUpgradePrompts() && runtimeCapabilitiesLoaded() && !hasAdvancedSSO()}>
<button
type="button"
onClick={() => setShowSamlUpsell(true)}
@@ -249,9 +254,7 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<div class="space-y-3">
<For each={providers()}>
{(provider) => (
<div
class={getSSOProviderCardClass(provider.enabled)}
>
<div class={getSSOProviderCardClass(provider.enabled)}>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3 min-w-0">
<div class="p-2 rounded-md bg-surface-hover">
@@ -266,9 +269,7 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
{getSSOProviderTypeLabel(provider.type)}
</span>
</div>
<p class="text-xs text-muted truncate">
{getSSOProviderSummary(provider)}
</p>
<p class="text-xs text-muted truncate">{getSSOProviderSummary(provider)}</p>
</div>
</div>
<div class="flex items-center gap-2 self-end sm:self-auto">
@@ -580,9 +581,7 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
{/* Test result display */}
<Show when={testResult()}>
<div
class={testResultPresentation()!.panelClass}
>
<div class={testResultPresentation()!.panelClass}>
<div class="flex items-start gap-3">
{testResult()?.success ? (
<CheckCircle class={testResultPresentation()!.iconClass} />
@@ -590,13 +589,9 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
<XCircle class={testResultPresentation()!.iconClass} />
)}
<div class="flex-1 min-w-0">
<p class={testResultPresentation()!.titleClass}>
{testResult()?.message}
</p>
<p class={testResultPresentation()!.titleClass}>{testResult()?.message}</p>
<Show when={testResult()?.error}>
<p class={testResultPresentation()!.errorClass}>
{testResult()?.error}
</p>
<p class={testResultPresentation()!.errorClass}>{testResult()?.error}</p>
</Show>
<Show when={testResult()?.success && testResult()?.details}>
<dl class="mt-2 grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 text-xs">
@@ -640,20 +635,18 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
);
return (
<div
class={certPresentation.containerClass}
>
<span class="font-medium">{cert.subject}</span>
<span class="mx-1"></span>
<span>
Expires: {new Date(cert.notAfter).toLocaleDateString()}
</span>
<Show when={cert.isExpired}>
<span class={certPresentation.expiredLabelClass}>
{certPresentation.expiredLabel}
<div class={certPresentation.containerClass}>
<span class="font-medium">{cert.subject}</span>
<span class="mx-1"></span>
<span>
Expires: {new Date(cert.notAfter).toLocaleDateString()}
</span>
</Show>
</div>
<Show when={cert.isExpired}>
<span class={certPresentation.expiredLabelClass}>
{certPresentation.expiredLabel}
</span>
</Show>
</div>
);
}}
</For>
@@ -853,18 +846,16 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
);
return (
<div
class={certPresentation.containerClass}
>
<span class="font-medium">{cert.subject}</span>
<span class="mx-1"></span>
<span>Expires: {new Date(cert.notAfter).toLocaleDateString()}</span>
<Show when={cert.isExpired}>
<span class={certPresentation.expiredLabelClass}>
{certPresentation.expiredLabel}
</span>
</Show>
</div>
<div class={certPresentation.containerClass}>
<span class="font-medium">{cert.subject}</span>
<span class="mx-1"></span>
<span>Expires: {new Date(cert.notAfter).toLocaleDateString()}</span>
<Show when={cert.isExpired}>
<span class={certPresentation.expiredLabelClass}>
{certPresentation.expiredLabel}
</span>
</Show>
</div>
);
}}
</For>
@@ -8,9 +8,7 @@ import Shield from 'lucide-solid/icons/shield';
import Pencil from 'lucide-solid/icons/pencil';
import { SearchField } from '@/components/shared/SearchField';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import {
getUserAssignmentsEmptyStateCopy,
} from '@/utils/rbacPresentation';
import { getUserAssignmentsEmptyStateCopy } from '@/utils/rbacPresentation';
export const UserAssignmentsPanel: Component = () => {
const state = useUserAssignmentsPanelState();
@@ -39,6 +37,7 @@ export const UserAssignmentsPanel: Component = () => {
canStartTrial={state.featureGate.canStartTrial()}
copy={state.featureGate.featureGateCopy()}
paywallLocation="settings_user_assignments_panel"
showUpgradePrompts={state.featureGate.showUpgradePrompts()}
startingTrial={state.featureGate.startingTrial()}
onStartTrial={state.featureGate.handleStartTrial}
/>
@@ -60,9 +59,7 @@ export const UserAssignmentsPanel: Component = () => {
<div class="text-center py-12 px-6">
<Users class="w-12 h-12 mx-auto text-slate-300 mb-4" />
<h4 class="text-base font-medium text-base-content mb-2">{emptyStateCopy().title}</h4>
<p class="text-sm text-muted max-w-md mx-auto">
{emptyStateCopy().body}
</p>
<p class="text-sm text-muted max-w-md mx-auto">{emptyStateCopy().body}</p>
<div class="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3 text-xs text-muted">
<span class="flex items-center gap-1.5">
<Shield class="w-3.5 h-3.5" />
@@ -28,6 +28,7 @@ const entitlementsMock = vi.fn();
const trackPaywallViewedMock = vi.fn();
const trackUpgradeClickedMock = vi.fn();
const runStartProTrialActionMock = vi.fn();
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
vi.mock('@/api/ai', () => ({
AIAPI: {
@@ -81,6 +82,10 @@ vi.mock('@/utils/trialStartAction', () => ({
runStartProTrialAction: (...args: unknown[]) => runStartProTrialActionMock(...args),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(),
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: (...args: unknown[]) => trackPaywallViewedMock(...args),
trackUpgradeClicked: (...args: unknown[]) => trackUpgradeClickedMock(...args),
@@ -133,6 +138,7 @@ const resetAllMocks = () => {
trackPaywallViewedMock.mockReset();
trackUpgradeClickedMock.mockReset();
runStartProTrialActionMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
};
const setupDefaultMocks = () => {
@@ -153,6 +159,7 @@ const setupDefaultMocks = () => {
getSessionDiffMock.mockResolvedValue({ files: [], summary: '' });
revertSessionMock.mockResolvedValue(undefined);
runStartProTrialActionMock.mockResolvedValue('activated');
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(false);
};
describe('AISettings model loading error states', () => {
@@ -337,7 +344,9 @@ describe('AISettings load failure error state', () => {
});
// Error banner should be gone
expect(screen.queryByText(/Unable to load Assistant & Patrol settings/)).not.toBeInTheDocument();
expect(
screen.queryByText(/Unable to load Assistant & Patrol settings/),
).not.toBeInTheDocument();
// Verify retry actually called getSettings again
expect(getSettingsMock).toHaveBeenCalledTimes(2);
@@ -514,4 +523,34 @@ describe('AISettings quickstart enablement flow', () => {
).toHaveLength(2);
expect(screen.getByRole('button', { name: /start trial/i })).toBeInTheDocument();
});
it('hides trial setup guidance when self-hosted upgrade prompts are disabled', async () => {
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
getSettingsMock.mockResolvedValue({
...baseSettings(),
configured: false,
enabled: false,
quickstart_credits_total: 0,
quickstart_credits_remaining: 0,
quickstart_credits_available: false,
quickstart_blocked_reason:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
});
renderComponent();
await waitFor(() => {
expect(getSettingsMock).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole('button', { name: /enable assistant and patrol/i }));
expect(updateSettingsMock).not.toHaveBeenCalled();
expect(await screen.findByText('Set Up Assistant & Patrol')).toBeInTheDocument();
expect(
screen.getByText('Connect a provider to power Pulse Assistant and Patrol.'),
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /start trial/i })).not.toBeInTheDocument();
expect(screen.queryByText(/start a trial to use Patrol quickstart/i)).not.toBeInTheDocument();
});
});
@@ -51,6 +51,10 @@ vi.mock('@/stores/licenseCommercial', () => ({
startProTrial: vi.fn(),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => false,
}));
vi.mock('@/api/agentProfiles', () => ({
MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE:
'Selected profile no longer exists. Refresh and choose another profile.',
@@ -233,7 +237,9 @@ beforeEach(() => {
href: getPublicPricingUrl(feature),
external: true,
}));
getUpgradeActionUrlOrFallbackMock.mockImplementation((feature?: string) => getPublicPricingUrl(feature));
getUpgradeActionUrlOrFallbackMock.mockImplementation((feature?: string) =>
getPublicPricingUrl(feature),
);
mockResources = [makeAgentResource()];
mockWsStore = {
state: {
@@ -642,7 +648,9 @@ describe('AgentProfilesPanel V6 agent ID handling', () => {
});
it('surfaces malformed profile list responses instead of failing open to an empty state', async () => {
listProfilesMock.mockRejectedValueOnce(new Error('Invalid agent profile list response from Pulse.'));
listProfilesMock.mockRejectedValueOnce(
new Error('Invalid agent profile list response from Pulse.'),
);
render(() => <AgentProfilesPanel />);
@@ -40,6 +40,7 @@ const clearLicenseMock = vi.fn();
const notificationSuccessMock = vi.fn();
const notificationErrorMock = vi.fn();
const presentationPolicyHidesCommercialSurfacesMock = vi.fn(() => false);
const presentationPolicyHidesUpgradePromptsMock = vi.fn(() => true);
const sessionPresentationPolicyResolvedMock = vi.fn(() => true);
const useLocationMock = vi.fn(() => ({
search: '',
@@ -79,6 +80,7 @@ vi.mock('@/stores/licenseEntitlements', () => ({
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesCommercialSurfaces: () => presentationPolicyHidesCommercialSurfacesMock(),
presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(),
sessionPresentationPolicyResolved: () => sessionPresentationPolicyResolvedMock(),
}));
@@ -131,6 +133,7 @@ describe('ProLicensePanel', () => {
notificationSuccessMock.mockReset();
notificationErrorMock.mockReset();
presentationPolicyHidesCommercialSurfacesMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
sessionPresentationPolicyResolvedMock.mockReset();
useLocationMock.mockReset();
navigateMock.mockReset();
@@ -144,6 +147,7 @@ describe('ProLicensePanel', () => {
activateLicenseMock.mockResolvedValue({ success: true });
clearLicenseMock.mockResolvedValue({ success: true });
presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(false);
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
sessionPresentationPolicyResolvedMock.mockReturnValue(true);
getUpgradeActionDestinationMock.mockImplementation((feature?: string) => ({
href: getPublicPricingUrl(feature),
@@ -210,40 +214,30 @@ describe('ProLicensePanel', () => {
).not.toBeInTheDocument();
});
it('shows the compare-plans prompt by default when no paid self-hosted plan is active', async () => {
it('keeps the self-hosted billing page non-promotional by default', async () => {
renderPanel();
await waitFor(() => {
expect(loadLicenseEntitlementsMock).toHaveBeenCalled();
});
expect(screen.getAllByText('Compare self-hosted plans').length).toBeGreaterThan(0);
expect(screen.getByText('Current plan: Community')).toBeInTheDocument();
expect(screen.getByText(/^Community$/)).toBeInTheDocument();
expect(screen.queryByText(/^Expired$/)).not.toBeInTheDocument();
expect(trackPricingViewedMock).toHaveBeenCalledWith(
'settings_self_hosted_billing_plan',
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
);
expect(
screen.getByText(/Community is active on this instance/i),
).toBeInTheDocument();
expect(screen.getByText('If You Need More')).toBeInTheDocument();
expect(screen.getByText('What Relay adds')).toBeInTheDocument();
expect(screen.getByText('What Pulse Pro adds')).toBeInTheDocument();
expect(screen.getByText('Unlimited self-hosted monitoring')).toBeInTheDocument();
const compareLinks = screen.getAllByRole('link', { name: 'Compare plans' });
expect(compareLinks).toHaveLength(1);
expect(
compareLinks.some(
(link) =>
link.getAttribute('href') ===
getSelfHostedPurchaseStartUrl(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT),
),
).toBe(true);
expect(screen.queryByText('Compare self-hosted plans')).not.toBeInTheDocument();
expect(screen.queryByText('If You Need More')).not.toBeInTheDocument();
expect(screen.queryByText('What Relay adds')).not.toBeInTheDocument();
expect(screen.queryByText('What Pulse Pro adds')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Compare plans' })).not.toBeInTheDocument();
expect(trackPricingViewedMock).not.toHaveBeenCalled();
});
it('tracks compare-plan checkout intent from the self-hosted billing prompt', async () => {
it('tracks compare-plan checkout intent from the explicit self-hosted billing handoff', async () => {
useLocationMock.mockReturnValue({
search: `?intent=${SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT}`,
pathname: '/settings/system/billing/plan',
hash: '',
});
renderPanel();
await waitFor(() => {
@@ -469,7 +463,9 @@ describe('ProLicensePanel', () => {
expect(screen.getByText('Advanced SSO (SAML/Multi-Provider)')).toBeInTheDocument();
expect(screen.queryByText('If You Need More')).not.toBeInTheDocument();
expect(screen.getByText('90 days')).toBeInTheDocument();
expect(screen.getByText('Root-cause analysis, remediation, and admin extras')).toBeInTheDocument();
expect(
screen.getByText('Root-cause analysis, remediation, and admin extras'),
).toBeInTheDocument();
expect(screen.queryByText('Guest Capacity')).not.toBeInTheDocument();
expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument();
expect(screen.queryByText('Monitored-system policy')).not.toBeInTheDocument();
@@ -499,13 +495,10 @@ describe('ProLicensePanel', () => {
'Relay is active on this instance. Remote access, mobile, push, and longer history are unlocked right now.',
),
).toBeInTheDocument();
expect(screen.getByText('If You Need More')).toBeInTheDocument();
expect(screen.queryByText('If You Need More')).not.toBeInTheDocument();
expect(screen.queryByText('What Relay adds')).not.toBeInTheDocument();
expect(screen.getByText('What Pulse Pro adds')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'See all plans' })).toHaveAttribute(
'href',
getSelfHostedPurchaseStartUrl(SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT),
);
expect(screen.queryByText('What Pulse Pro adds')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'See all plans' })).not.toBeInTheDocument();
expect(screen.getByText('Pulse Relay (Remote Access)')).toBeInTheDocument();
expect(screen.getByText('Mobile App Access')).toBeInTheDocument();
expect(screen.getByText('Push Notifications')).toBeInTheDocument();
@@ -767,9 +760,7 @@ describe('ProLicensePanel', () => {
renderPanel();
expect(screen.getByText('Pulse Pro trial is now active')).toBeInTheDocument();
expect(
screen.getByText(/this instance now has Pulse Pro trial access/i),
).toBeInTheDocument();
expect(screen.getByText(/this instance now has Pulse Pro trial access/i)).toBeInTheDocument();
expect(screen.getByText('Available during this trial')).toBeInTheDocument();
expect(screen.getAllByText('Patrol Auto-Fix').length).toBeGreaterThan(0);
expect(navigateMock).toHaveBeenCalledWith(SELF_HOSTED_PRO_BILLING_PLAN_HREF, {
@@ -931,7 +922,9 @@ describe('ProLicensePanel', () => {
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Usage' })).toHaveAttribute('aria-selected', 'true');
expect(document.getElementById(SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID)).not.toBeInTheDocument();
expect(
document.getElementById(SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID),
).not.toBeInTheDocument();
expect(document.getElementById(SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID)).toBeInTheDocument();
});
expect(screen.getByText('Monitored Systems')).toBeInTheDocument();
@@ -952,7 +945,9 @@ describe('ProLicensePanel', () => {
});
expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument();
expect(document.getElementById(SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID)).not.toBeInTheDocument();
expect(
document.getElementById(SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID),
).not.toBeInTheDocument();
expect(document.getElementById(SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID)).toBeInTheDocument();
expect(navigateMock).toHaveBeenCalledWith(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, {
replace: true,
@@ -970,9 +965,7 @@ describe('ProLicensePanel', () => {
renderPanel();
expect(screen.getAllByText('Compare self-hosted plans').length).toBeGreaterThan(0);
expect(
screen.getByText(/Community is active on this instance/i),
).toBeInTheDocument();
expect(screen.getByText(/Community is active on this instance/i)).toBeInTheDocument();
const compareLinks = screen.getAllByRole('link', { name: 'Compare plans' });
expect(
compareLinks.some(
@@ -1126,7 +1119,7 @@ describe('ProLicensePanel', () => {
expect(proLicensePanelStateSource).toContain('getSelfHostedCurrentPlanStatusPresentation');
expect(proLicensePanelStateSource).toContain('getSelfHostedPlanComparisonPresentation({');
expect(proLicensePanelStateSource).toContain('getSelfHostedActivationSuccessPresentation({');
expect(proLicensePanelStateSource).toContain('runStartProTrialAction({');
expect(proLicensePanelStateSource).not.toContain('runStartProTrialAction({');
expect(proLicensePanelStateSource).not.toContain('startProTrial()');
expect(proLicensePanelStateSource).toContain("'A license or activation key is required'");
expect(proLicensePlanSectionSource).toContain('getLicenseStatusLoadingState');
@@ -10,6 +10,7 @@ const loadCommercialPostureMock = vi.fn();
const startProTrialMock = vi.fn();
const commercialPostureMock = vi.fn();
const entitlementsMock = vi.fn();
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
const trackPaywallViewedMock = vi.fn();
const trackUpgradeClickedMock = vi.fn();
const getRolesMock = vi.fn();
@@ -40,6 +41,10 @@ vi.mock('@/stores/licenseCommercial', () => ({
entitlements: (...args: unknown[]) => entitlementsMock(...args),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(),
}));
vi.mock('@/utils/upgradeMetrics', () => ({
trackPaywallViewed: (...args: unknown[]) => trackPaywallViewedMock(...args),
trackUpgradeClicked: (...args: unknown[]) => trackUpgradeClickedMock(...args),
@@ -79,6 +84,7 @@ describe('RBAC paywall settings panels', () => {
startProTrialMock.mockReset();
commercialPostureMock.mockReset();
entitlementsMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
trackPaywallViewedMock.mockReset();
trackUpgradeClickedMock.mockReset();
getRolesMock.mockReset();
@@ -95,6 +101,7 @@ describe('RBAC paywall settings panels', () => {
startProTrialMock.mockResolvedValue({ outcome: 'started' });
commercialPostureMock.mockReturnValue({ trial_eligible: false });
entitlementsMock.mockReturnValue({ trial_eligible: false });
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(false);
getRolesMock.mockResolvedValue([
{
id: 'admin',
@@ -136,6 +143,22 @@ describe('RBAC paywall settings panels', () => {
expect(trackPaywallViewedMock).toHaveBeenCalledWith('rbac', 'settings_roles_panel');
});
it('keeps RBAC upgrade actions quiet when self-hosted upgrade prompts are hidden', async () => {
hasFeatureMock.mockImplementation((feature: string) => feature !== 'rbac');
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
render(() => <RolesPanel />);
await waitFor(() => {
expect(screen.getByText('Custom Roles (Pro)')).toBeInTheDocument();
});
expect(screen.queryByRole('link', { name: 'Upgrade to Pro' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Start free trial' })).not.toBeInTheDocument();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
expect(getRolesMock).not.toHaveBeenCalled();
});
it('loads roles when the RBAC entitlement is granted', async () => {
render(() => <RolesPanel />);
@@ -184,7 +207,10 @@ describe('RBAC paywall settings panels', () => {
it('shows the canonical update error when self role modification is denied', async () => {
updateUserRolesMock.mockRejectedValueOnce(
Object.assign(new Error('Cannot modify your own role assignments'), { status: 403, code: 'self_modification_denied' }),
Object.assign(new Error('Cannot modify your own role assignments'), {
status: 403,
code: 'self_modification_denied',
}),
);
render(() => <UserAssignmentsPanel />);
@@ -32,6 +32,10 @@ vi.mock('@/stores/licenseCommercial', () => ({
startProTrial: vi.fn(),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => false,
}));
vi.mock('@/api/relay', () => ({
RelayAPI: {
getConfig: (...args: unknown[]) => getRelayConfigMock(...args),
@@ -310,13 +314,17 @@ describe('RelaySettingsPanel runtime', () => {
fireEvent.click(screen.getByRole('button', { name: 'Pair New Device' }));
await waitFor(() => {
expect(screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-123')).toBeInTheDocument();
expect(
screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-123'),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Refresh QR Code' }));
await waitFor(() => {
expect(screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-456')).toBeInTheDocument();
expect(
screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-456'),
).toBeInTheDocument();
expect(getTokenMock).toHaveBeenCalledWith('relay-token-1');
expect(deleteTokenMock).toHaveBeenCalledWith('relay-token-1');
});
@@ -365,15 +373,14 @@ describe('RelaySettingsPanel runtime', () => {
auth_token: 'token-456',
deep_link: 'pulse://connect?instance_id=instance-local&auth_token=token-456',
});
getTokenMock
.mockResolvedValueOnce({
id: 'relay-token-1',
name: 'Pulse Mobile relay access first',
prefix: 'pmp_',
suffix: '1234',
createdAt: '',
lastUsedAt: '2026-03-24T12:00:00Z',
});
getTokenMock.mockResolvedValueOnce({
id: 'relay-token-1',
name: 'Pulse Mobile relay access first',
prefix: 'pmp_',
suffix: '1234',
createdAt: '',
lastUsedAt: '2026-03-24T12:00:00Z',
});
render(() => <RelaySettingsPanel canManage />);
@@ -384,13 +391,17 @@ describe('RelaySettingsPanel runtime', () => {
fireEvent.click(screen.getByRole('button', { name: 'Pair New Device' }));
await waitFor(() => {
expect(screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-123')).toBeInTheDocument();
expect(
screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-123'),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Refresh QR Code' }));
await waitFor(() => {
expect(screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-456')).toBeInTheDocument();
expect(
screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-456'),
).toBeInTheDocument();
expect(getTokenMock).toHaveBeenCalledWith('relay-token-1');
});
@@ -464,7 +475,9 @@ describe('RelaySettingsPanel runtime', () => {
fireEvent.click(screen.getByRole('button', { name: 'Pair New Device' }));
await waitFor(() => {
expect(screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-123')).toBeInTheDocument();
expect(
screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-123'),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Refresh QR Code' }));
@@ -473,7 +486,9 @@ describe('RelaySettingsPanel runtime', () => {
expect(deleteTokenMock).toHaveBeenCalledWith('relay-token-2');
});
expect(screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-123')).toBeInTheDocument();
expect(
screen.getByText('pulse://connect?instance_id=instance-local&auth_token=token-123'),
).toBeInTheDocument();
expect(showErrorMock).toHaveBeenCalledWith('Failed to generate pairing QR code');
});
@@ -11,9 +11,11 @@ vi.mock('@/components/Settings/useReportingPanelState', () => ({
}));
vi.mock('../ResourcePicker', () => ({
ResourcePicker: (_props: { maxSelection?: number; selected: () => unknown[]; onSelectionChange: (items: unknown[]) => void }): JSX.Element => (
<div>Mock Resource Picker</div>
),
ResourcePicker: (_props: {
maxSelection?: number;
selected: () => unknown[];
onSelectionChange: (items: unknown[]) => void;
}): JSX.Element => <div>Mock Resource Picker</div>,
}));
vi.mock('@/utils/upgradeMetrics', () => ({
@@ -95,6 +97,7 @@ function buildState(overrides: Record<string, unknown> = {}) {
setRange: vi.fn(),
setSelectedResources: vi.fn(),
setTitle: vi.fn(),
showUpgradePrompts: () => true,
startingTrial: () => false,
title: () => '',
upgradeDestination: () => ({
@@ -109,6 +109,11 @@ describe('settings architecture guardrails', () => {
expect(settingsNavCatalogSource).toContain(
'label: SELF_HOSTED_PRO_BILLING_PRESENTATION.navLabel',
);
expect(settingsNavCatalogSource).toContain('hideFromSidebar: true');
expect(settingsNavCatalogSource).toContain("features: ['rbac']");
expect(settingsNavCatalogSource).toContain("features: ['audit_logging']");
expect(settingsNavCatalogSource).toContain("features: ['relay']");
expect(settingsNavCatalogSource).toContain('hideWhenUnavailable: true');
expect(settingsHeaderMetaSource).toContain(
'title: SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle',
);
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { deriveTabFromPath, settingsTabPath, type SettingsTab } from '../settingsNavigationModel';
import { getTabLockReason, isTabLocked } from '../settingsFeatureGates';
import { getSettingsNavItem } from '../settingsNavCatalog';
import { shouldHideSettingsNavItem } from '../settingsNavVisibility';
import { getSettingsTabSaveBehavior } from '../settingsTabSaveBehavior';
import { updateDisableLocalUpgradeMetricsSetting } from '@/stores/systemSettings';
@@ -117,6 +118,38 @@ describe('settingsNavigation integration scaffold', () => {
).toBe(true);
});
it('keeps self-hosted billing as a direct route instead of a sidebar item', () => {
expect(deriveTabFromPath('/settings/system/billing/plan')).toBe('system-billing');
expect(settingsTabPath('system-billing')).toBe('/settings/system/billing/plan');
expect(getSettingsNavItem('system-billing')?.hideFromSidebar).toBe(true);
});
it('hides paid-only self-hosted tabs from free installs', () => {
for (const tab of [
'system-relay',
'security-roles',
'security-users',
'security-audit',
'security-webhooks',
] as SettingsTab[]) {
expect(
shouldHideSettingsNavItem(tab, {
hasFeature: hasFeatures([]),
runtimeCapabilitiesLoaded: () => true,
hostedModeEnabled: false,
settingsCapabilitiesResolved: true,
settingsCapabilities: {
auditLog: true,
auditWebhooksRead: true,
relayRead: true,
roles: true,
users: true,
},
}),
).toBe(true);
}
});
it('hides support tabs in demo mode and before demo policy resolves', () => {
expect(
shouldHideSettingsNavItem('support-diagnostics', {
@@ -114,7 +114,9 @@ describe('useReportingPanelState', () => {
}));
vi.doMock('@/stores/license', () => ({
hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature),
hasFeature: vi.fn(
(feature: string) => feature === 'advanced_reporting' && hasReportingFeature,
),
runtimeCapabilitiesLoaded: vi.fn(() => true),
loadRuntimeCapabilities: loadRuntimeLicenseStatusMock,
}));
@@ -131,6 +133,10 @@ describe('useReportingPanelState', () => {
startProTrial: vi.fn(),
}));
vi.doMock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: vi.fn(() => false),
}));
({ useReportingPanelState } = await import('../useReportingPanelState'));
});
@@ -278,7 +284,9 @@ describe('useReportingPanelState', () => {
}));
vi.doMock('@/stores/license', () => ({
hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature),
hasFeature: vi.fn(
(feature: string) => feature === 'advanced_reporting' && hasReportingFeature,
),
runtimeCapabilitiesLoaded: vi.fn(() => true),
loadRuntimeCapabilities: loadRuntimeLicenseStatusMock,
}));
@@ -319,7 +327,9 @@ describe('useReportingPanelState', () => {
apiFetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'Catalog is unavailable right now' }), { status: 503 }),
new Response(JSON.stringify({ error: 'Catalog is unavailable right now' }), {
status: 503,
}),
);
vi.doMock('@/utils/apiClient', async () => {
@@ -344,7 +354,9 @@ describe('useReportingPanelState', () => {
}));
vi.doMock('@/stores/license', () => ({
hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature),
hasFeature: vi.fn(
(feature: string) => feature === 'advanced_reporting' && hasReportingFeature,
),
runtimeCapabilitiesLoaded: vi.fn(() => true),
loadRuntimeCapabilities: loadRuntimeLicenseStatusMock,
}));
@@ -375,7 +387,9 @@ describe('useReportingPanelState', () => {
it('falls back to the legacy reporting transport when the catalog route is missing', async () => {
vi.resetModules();
apiFetchMock = vi.fn().mockResolvedValueOnce(new Response('404 page not found', { status: 404 }));
apiFetchMock = vi
.fn()
.mockResolvedValueOnce(new Response('404 page not found', { status: 404 }));
vi.doMock('@/utils/apiClient', async () => {
const actual = await vi.importActual<typeof import('@/utils/apiClient')>('@/utils/apiClient');
@@ -399,7 +413,9 @@ describe('useReportingPanelState', () => {
}));
vi.doMock('@/stores/license', () => ({
hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature),
hasFeature: vi.fn(
(feature: string) => feature === 'advanced_reporting' && hasReportingFeature,
),
runtimeCapabilitiesLoaded: vi.fn(() => true),
loadRuntimeCapabilities: loadRuntimeLicenseStatusMock,
}));
@@ -422,7 +438,9 @@ describe('useReportingPanelState', () => {
await flushAsync();
expect(hookState.reportingCatalogError()).toBe('');
expect(hookState.reportingCatalog()?.performanceReport.singleResourceEndpoint).toBe('/api/reporting');
expect(hookState.reportingCatalog()?.performanceReport.singleResourceEndpoint).toBe(
'/api/reporting',
);
expect(hookState.reportingCatalog()?.vmInventoryExport).toBeNull();
dispose();
@@ -20,11 +20,7 @@ import FileText from 'lucide-solid/icons/file-text';
import Terminal from 'lucide-solid/icons/terminal';
import { PulseLogoIcon } from '@/components/icons/PulseLogoIcon';
import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation';
import type {
SettingsNavGroup,
SettingsNavItem,
SettingsTab,
} from './settingsNavigationModel';
import type { SettingsNavGroup, SettingsNavItem, SettingsTab } from './settingsNavigationModel';
export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
{
@@ -136,6 +132,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
id: 'system-billing',
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.navLabel,
icon: PulseLogoIcon,
hideFromSidebar: true,
hideWhenCommercialHidden: true,
},
],
@@ -202,6 +199,8 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
label: 'Roles',
icon: ShieldCheck,
iconProps: { strokeWidth: 2 },
features: ['rbac'],
hideWhenUnavailable: true,
requiredCapability: 'roles',
},
{
@@ -209,6 +208,8 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
label: 'Users',
icon: Users,
iconProps: { strokeWidth: 2 },
features: ['rbac'],
hideWhenUnavailable: true,
requiredCapability: 'users',
},
{
@@ -216,6 +217,8 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
label: 'Audit Log',
icon: Activity,
iconProps: { strokeWidth: 2 },
features: ['audit_logging'],
hideWhenUnavailable: true,
requiredCapability: 'auditLog',
},
{
@@ -224,6 +227,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
icon: Globe,
iconProps: { strokeWidth: 2 },
features: ['audit_logging'],
hideWhenUnavailable: true,
requiredCapability: 'auditWebhooksRead',
},
{
@@ -232,6 +236,7 @@ export const SETTINGS_NAV_GROUPS: SettingsNavGroup[] = [
icon: RadioTower,
iconProps: { strokeWidth: 2 },
features: ['relay'],
hideWhenUnavailable: true,
requiredCapability: 'relayRead',
},
],
@@ -16,20 +16,12 @@ import {
loadAIRuntimeModels,
syncAIRuntimeSettings,
} from '@/stores/aiRuntimeState';
import {
hasFeature,
loadRuntimeCapabilities,
} from '@/stores/license';
import {
canOfferCommercialTrial,
getUpgradeActionDestination,
} from '@/stores/licenseCommercial';
import { hasFeature, loadRuntimeCapabilities } from '@/stores/license';
import { canOfferCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { notificationStore } from '@/stores/notifications';
import type { AISettings as AISettingsType, AIProvider, AuthMethod } from '@/types/ai';
import {
normalizeAIControlLevel,
type AIControlLevel,
} from '@/utils/aiControlLevelPresentation';
import { normalizeAIControlLevel, type AIControlLevel } from '@/utils/aiControlLevelPresentation';
import { getAIProviderDisplayName, getProviderFromModelId } from '@/utils/aiProviderPresentation';
import {
getAICredentialsClearErrorMessage,
@@ -126,6 +118,15 @@ export const useAISettingsState = () => {
discoveryIntervalHours: 0,
});
const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts();
const visibleQuickstartBlockedReason = () => {
const normalized = normalizeQuickstartReason(settings()?.quickstart_blocked_reason);
if (normalized === AI_QUICKSTART_ACTIVATION_REQUIRED_REASON && !showUpgradePrompts()) {
return '';
}
return normalized;
};
const settingsReadiness = createMemo(() =>
getAISettingsReadinessPresentation({
configured: Boolean(settings()?.configured),
@@ -134,7 +135,7 @@ export const useAISettingsState = () => {
quickstartCreditsAvailable: Boolean(settings()?.quickstart_credits_available),
quickstartCreditsRemaining: settings()?.quickstart_credits_remaining ?? 0,
quickstartCreditsTotal: settings()?.quickstart_credits_total ?? 0,
quickstartBlockedReason: normalizeQuickstartReason(settings()?.quickstart_blocked_reason),
quickstartBlockedReason: visibleQuickstartBlockedReason(),
}),
);
const autoFixLocked = createMemo(() => !hasFeature('ai_autofix'));
@@ -150,19 +151,19 @@ export const useAISettingsState = () => {
const current = settings();
return Boolean(
current &&
(current.anthropic_configured ||
current.openai_configured ||
current.openrouter_configured ||
current.deepseek_configured ||
current.gemini_configured ||
current.ollama_configured),
(current.anthropic_configured ||
current.openai_configured ||
current.openrouter_configured ||
current.deepseek_configured ||
current.gemini_configured ||
current.ollama_configured),
);
});
const hasQuickstartAvailable = createMemo(() => Boolean(settings()?.quickstart_credits_available));
const quickstartBlockedReason = createMemo(() =>
normalizeQuickstartReason(settings()?.quickstart_blocked_reason),
const hasQuickstartAvailable = createMemo(() =>
Boolean(settings()?.quickstart_credits_available),
);
const canStartTrial = () => canOfferCommercialTrial();
const quickstartBlockedReason = createMemo(visibleQuickstartBlockedReason);
const canStartTrial = () => showUpgradePrompts() && canOfferCommercialTrial();
const upgradeAutofixDestination = () => getUpgradeActionDestination('ai_autofix');
const hasProviderBackedModels = (data: AISettingsType | null | undefined) =>
@@ -181,7 +182,7 @@ export const useAISettingsState = () => {
createEffect((wasPaywallVisible) => {
const isPaywallVisible = form.controlLevel === 'autonomous' && autoFixLocked();
if (isPaywallVisible && !wasPaywallVisible) {
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('ai_autofix', 'settings_ai_patrol_autofix');
}
return isPaywallVisible;
@@ -333,7 +334,7 @@ export const useAISettingsState = () => {
const openEnableSetupModal = () => {
const blockedReason = quickstartBlockedReason();
if (blockedReason === AI_QUICKSTART_ACTIVATION_REQUIRED_REASON) {
if (blockedReason === AI_QUICKSTART_ACTIVATION_REQUIRED_REASON && showUpgradePrompts()) {
setSetupMode('activation-or-provider');
} else if (blockedReason) {
setSetupMode('provider-required');
@@ -544,7 +545,9 @@ export const useAISettingsState = () => {
setShowDiffModal(true);
} catch (error) {
logger.error('[AISettings] Failed to get session diff:', error);
notificationStore.error(getAISessionDiffErrorMessage(error instanceof Error ? error.message : ''));
notificationStore.error(
getAISessionDiffErrorMessage(error instanceof Error ? error.message : ''),
);
} finally {
setSessionActionLoading(null);
}
@@ -704,7 +707,9 @@ export const useAISettingsState = () => {
notificationStore.success('Assistant & Patrol settings saved');
} catch (error) {
logger.error('[AISettings] Failed to save settings:', error);
notificationStore.error(getAISettingsSaveErrorMessage(error instanceof Error ? error.message : ''));
notificationStore.error(
getAISettingsSaveErrorMessage(error instanceof Error ? error.message : ''),
);
} finally {
setSaving(false);
}
@@ -811,7 +816,9 @@ export const useAISettingsState = () => {
setSettings(updated);
syncModelCatalogForSettings(updated);
void runProviderPreflight(updated);
notificationStore.success(newValue ? 'Assistant & Patrol enabled' : 'Assistant & Patrol disabled');
notificationStore.success(
newValue ? 'Assistant & Patrol enabled' : 'Assistant & Patrol disabled',
);
} catch (error) {
setForm('enabled', !newValue);
logger.error('[AISettings] Failed to toggle AI:', error);
@@ -922,6 +929,7 @@ export const useAISettingsState = () => {
showDiffModal,
showDiscoverySettings,
showSetupModal,
showUpgradePrompts,
startingTrial,
testing,
testingProvider,
@@ -14,10 +14,8 @@ import {
runtimeCapabilitiesLoaded,
runtimeCapabilitiesLoading,
} from '@/stores/license';
import {
canOfferCommercialTrial,
getUpgradeActionDestination,
} from '@/stores/licenseCommercial';
import { canOfferCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { aiChatStore } from '@/stores/aiChat';
import type { ConnectedInfrastructureItem } from '@/types/api';
@@ -74,7 +72,8 @@ export const useAgentProfilesPanelState = () => {
const checkingLicense = () => !runtimeCapabilitiesLoaded() || runtimeCapabilitiesLoading();
const hasAgentProfiles = () => hasEntitlement('agent_profiles');
const [startingTrial, setStartingTrial] = createSignal(false);
const canStartTrial = () => canOfferCommercialTrial();
const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts();
const canStartTrial = () => showUpgradePrompts() && canOfferCommercialTrial();
const aiAvailable = createMemo(() => aiChatStore.enabled === true);
const [profiles, setProfiles] = createSignal<AgentProfile[]>([]);
@@ -226,7 +225,7 @@ export const useAgentProfilesPanelState = () => {
createEffect((wasPaywallVisible) => {
const isPaywallVisible = !checkingLicense() && !hasAgentProfiles();
if (isPaywallVisible && !wasPaywallVisible) {
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('agent_profiles', 'settings_agent_profiles_panel');
}
return isPaywallVisible;
@@ -338,10 +337,7 @@ export const useAgentProfilesPanelState = () => {
await loadData();
} catch (err) {
logger.error('Failed to assign profile', err);
if (
err instanceof Error &&
err.message === MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE
) {
if (err instanceof Error && err.message === MISSING_AGENT_PROFILE_ASSIGNMENT_MESSAGE) {
await loadData();
}
notificationStore.error(
@@ -393,6 +389,7 @@ export const useAgentProfilesPanelState = () => {
setFormName,
setShowModal,
setShowSuggestModal,
showUpgradePrompts,
showModal,
showSuggestModal,
startingTrial,
@@ -7,14 +7,9 @@ import {
} from '@/utils/localStorage';
import { apiFetch } from '@/utils/apiClient';
import { showSuccess, showToast, showWarning } from '@/utils/toast';
import {
hasFeature,
runtimeCapabilitiesLoaded,
} from '@/stores/license';
import {
canOfferCommercialTrial,
getUpgradeActionDestination,
} from '@/stores/licenseCommercial';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { canOfferCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import { runStartProTrialAction } from '@/utils/trialStartAction';
@@ -115,11 +110,14 @@ export const useAuditLogPanelState = () => {
);
const [startingTrial, setStartingTrial] = createSignal(false);
const auditLoggingEnabled = createMemo(() => runtimeCapabilitiesLoaded() && hasFeature('audit_logging'));
const auditLoggingEnabled = createMemo(
() => runtimeCapabilitiesLoaded() && hasFeature('audit_logging'),
);
const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts();
const showUpgradePaywall = createMemo(
() => runtimeCapabilitiesLoaded() && !auditLoggingEnabled() && !loading(),
);
const canStartTrial = () => canOfferCommercialTrial();
const canStartTrial = () => showUpgradePrompts() && canOfferCommercialTrial();
const upgradeDestination = createMemo(() => getUpgradeActionDestination('audit_logging'));
const fetchAuditEvents = async (options?: { limit?: number; offset?: number }) => {
@@ -535,7 +533,7 @@ export const useAuditLogPanelState = () => {
createEffect((wasPaywallVisible) => {
const isPaywallVisible = showUpgradePaywall();
if (isPaywallVisible && !wasPaywallVisible) {
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('audit_logging', 'settings_audit_log_panel');
}
return isPaywallVisible;
@@ -630,6 +628,7 @@ export const useAuditLogPanelState = () => {
setUserFilter,
setVerificationFilter,
showUpgradePaywall,
showUpgradePrompts,
startingTrial,
submitPageInput,
successFilter,
@@ -2,14 +2,9 @@ import { createEffect, createSignal, onMount } from 'solid-js';
import { apiFetchJSON } from '@/utils/apiClient';
import { logger } from '@/utils/logger';
import { showSuccess, showWarning } from '@/utils/toast';
import {
hasFeature,
runtimeCapabilitiesLoaded,
} from '@/stores/license';
import {
canOfferCommercialTrial,
getUpgradeActionDestination,
} from '@/stores/licenseCommercial';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { canOfferCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
@@ -28,7 +23,8 @@ export const useAuditWebhookPanelState = (canManageOverride?: boolean) => {
const [startingTrial, setStartingTrial] = createSignal(false);
const canManage = () => canManageOverride !== false;
const canStartTrial = () => canOfferCommercialTrial();
const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts();
const canStartTrial = () => showUpgradePrompts() && canOfferCommercialTrial();
const isAuditLoggingEnabled = () => hasFeature('audit_logging');
const upgradeDestination = () => getUpgradeActionDestination('audit_logging');
@@ -105,7 +101,7 @@ export const useAuditWebhookPanelState = (canManageOverride?: boolean) => {
createEffect((wasPaywallVisible: boolean) => {
const isPaywallVisible = runtimeCapabilitiesLoaded() && !hasFeature('audit_logging');
if (isPaywallVisible && !wasPaywallVisible) {
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('audit_logging', 'settings_audit_webhook_panel');
}
return isPaywallVisible;
@@ -130,6 +126,7 @@ export const useAuditWebhookPanelState = (canManageOverride?: boolean) => {
newUrl,
saving,
setNewUrl,
showUpgradePrompts,
startingTrial,
upgradeDestination,
webhookUrls,
@@ -62,7 +62,6 @@ import {
getMonitoredSystemLimitUsageSummary,
resolveMonitoredSystemCapacityStatus,
} from '@/utils/monitoredSystemPresentation';
import { runStartProTrialAction } from '@/utils/trialStartAction';
import { trackCheckoutClicked, trackPricingViewed } from '@/utils/upgradeMetrics';
import { resolveUpgradeDestination, type UpgradeDestination } from '@/utils/upgradeNavigation';
import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation';
@@ -91,7 +90,6 @@ export function useProLicensePanelState() {
);
const [activating, setActivating] = createSignal(false);
const [clearing, setClearing] = createSignal(false);
const [startingTrial, setStartingTrial] = createSignal(false);
const [trialActivationResult, setTrialActivationResult] = createSignal('');
const [purchaseActivationResult, setPurchaseActivationResult] = createSignal('');
const [purchaseActivationIntent, setPurchaseActivationIntent] =
@@ -216,7 +214,11 @@ export function useProLicensePanelState() {
let trackedPlanPricingView = false;
createEffect(() => {
const planVisible = panelDataSettled() && activeSection() === 'plan';
const planVisible =
panelDataSettled() &&
activeSection() === 'plan' &&
getSelfHostedBillingPlanIntent(location.search) ===
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT;
if (!planVisible) {
trackedPlanPricingView = false;
return;
@@ -224,7 +226,10 @@ export function useProLicensePanelState() {
if (trackedPlanPricingView) {
return;
}
trackPricingViewed('settings_self_hosted_billing_plan', SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT);
trackPricingViewed(
'settings_self_hosted_billing_plan',
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
);
trackedPlanPricingView = true;
});
@@ -253,27 +258,12 @@ export function useProLicensePanelState() {
activeSection() === 'plan' &&
getSelfHostedBillingPlanDetail(location.search) === SELF_HOSTED_PRO_BILLING_RECOVERY_DETAIL,
);
const showDefaultPlanSelectionPrompt = createMemo(() => {
const current = entitlements();
if (!current) {
return false;
}
if (!panelDataSettled() || licenseEntitlementsLoadError()) {
return false;
}
if (current.commercial_migration?.state) {
return false;
}
const state = subscriptionState();
return state !== 'active' && state !== 'trial' && state !== 'grace';
});
const showPlanSelectionPrompt = createMemo(
() =>
activeSection() === 'plan' &&
purchaseActivationResult().trim().length === 0 &&
(getSelfHostedBillingPlanIntent(location.search) ===
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT ||
showDefaultPlanSelectionPrompt()),
getSelfHostedBillingPlanIntent(location.search) ===
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
);
const planSelectionPrompt = createMemo(() => {
if (!showPlanSelectionPrompt()) {
@@ -297,31 +287,6 @@ export function useProLicensePanelState() {
);
};
const showTrialStart = createMemo(() => {
const current = entitlements();
if (!current) return false;
if (current.commercial_migration?.state) return false;
if (typeof current.trial_eligible === 'boolean') {
return current.trial_eligible;
}
const state = subscriptionState();
return state !== 'active' && state !== 'trial' && !licenseEntitlementsLoadError();
});
const handleStartTrial = async () => {
if (startingTrial()) return;
setStartingTrial(true);
try {
await runStartProTrialAction({
branded: true,
showSuccess: notificationStore.success,
showError: notificationStore.error,
});
} finally {
setStartingTrial(false);
}
};
const statusPresentation = createMemo(() =>
getLicenseSubscriptionStatusPresentation(subscriptionState()),
);
@@ -588,15 +553,16 @@ export function useProLicensePanelState() {
};
});
const planComparisonSummary = createMemo(() => {
if (!showPlanSelectionPrompt()) {
return { cards: [], action: null };
}
const comparison = getSelfHostedPlanComparisonPresentation({
entitlements: entitlements(),
});
return {
...comparison,
action:
comparison.cards.length > 0 &&
!showPlanSelectionPrompt() &&
purchaseActivationResult().trim().length === 0
comparison.cards.length > 0 && purchaseActivationResult().trim().length === 0
? {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
destination: resolveSelfHostedPurchaseStartDestination(
@@ -675,7 +641,6 @@ export function useProLicensePanelState() {
grandfatheredPriceNotice,
handleActivate,
handleClear,
handleStartTrial,
hasLicenseDetails,
licenseKey,
loadPanelData,
@@ -691,8 +656,6 @@ export function useProLicensePanelState() {
showUsageSection,
showCountingRulesByDefault,
showRecoveryByDefault,
showTrialStart,
startingTrial,
statusPresentation,
trialActivationNotice,
trialEnded,
@@ -1,11 +1,7 @@
import { Accessor, createEffect, createMemo, createSignal, onMount } from 'solid-js';
import {
hasFeature,
runtimeCapabilitiesLoaded,
} from '@/stores/license';
import {
canOfferCommercialTrial,
} from '@/stores/licenseCommercial';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { canOfferCommercialTrial } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { notificationStore } from '@/stores/notifications';
import { getRBACFeatureGateCopy, type RBACFeatureGateCopy } from '@/utils/rbacPresentation';
@@ -13,9 +9,7 @@ import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import { runStartProTrialAction } from '@/utils/trialStartAction';
export type RBACFeatureGateKind = 'roles' | 'user-assignments';
export type RBACFeatureGateLocation =
| 'settings_roles_panel'
| 'settings_user_assignments_panel';
export type RBACFeatureGateLocation = 'settings_roles_panel' | 'settings_user_assignments_panel';
interface UseRBACFeatureGateStateOptions {
kind: RBACFeatureGateKind;
@@ -30,7 +24,8 @@ export function useRBACFeatureGateState(options: UseRBACFeatureGateStateOptions)
getRBACFeatureGateCopy(options.kind),
);
const licenseReady = createMemo(() => runtimeCapabilitiesLoaded());
const canStartTrial = createMemo(() => canOfferCommercialTrial());
const showUpgradePrompts = createMemo(() => !presentationPolicyHidesUpgradePrompts());
const canStartTrial = createMemo(() => showUpgradePrompts() && canOfferCommercialTrial());
const rbacEnabled = createMemo(() => licenseReady() && hasFeature('rbac'));
const paywallVisible = createMemo(
() => licenseReady() && !hasFeature('rbac') && !options.loading(),
@@ -42,7 +37,7 @@ export function useRBACFeatureGateState(options: UseRBACFeatureGateStateOptions)
createEffect((wasPaywallVisible) => {
const isPaywallVisible = paywallVisible();
if (isPaywallVisible && !wasPaywallVisible) {
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('rbac', options.paywallLocation);
}
return isPaywallVisible;
@@ -68,6 +63,7 @@ export function useRBACFeatureGateState(options: UseRBACFeatureGateStateOptions)
licenseReady,
paywallVisible,
rbacEnabled,
showUpgradePrompts,
startingTrial,
};
}
@@ -1,12 +1,7 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import {
hasFeature,
runtimeCapabilitiesLoaded,
} from '@/stores/license';
import {
canOfferCommercialTrial,
getUpgradeActionDestination,
} from '@/stores/licenseCommercial';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { canOfferCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import { showError, showSuccess } from '@/utils/toast';
@@ -36,7 +31,8 @@ export function useRelaySettingsPanelState(props: RelaySettingsPanelProps) {
const [startingTrial, setStartingTrial] = createSignal(false);
const canManage = () => props.canManage !== false;
const canStartTrial = () => canOfferCommercialTrial();
const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts();
const canStartTrial = () => showUpgradePrompts() && canOfferCommercialTrial();
const relayEnabled = () => hasFeature('relay');
const upgradeDestination = () => getUpgradeActionDestination('relay');
const connectionPresentation = createMemo(() =>
@@ -46,7 +42,7 @@ export function useRelaySettingsPanelState(props: RelaySettingsPanelProps) {
createEffect((wasPaywallVisible: boolean) => {
const isPaywallVisible = runtimeCapabilitiesLoaded() && !relayEnabled();
if (isPaywallVisible && !wasPaywallVisible) {
if (showUpgradePrompts() && isPaywallVisible && !wasPaywallVisible) {
trackPaywallViewed('relay', 'settings_relay_panel');
}
return isPaywallVisible;
@@ -287,6 +283,7 @@ export function useRelaySettingsPanelState(props: RelaySettingsPanelProps) {
saving,
serverUrl,
setServerUrl,
showUpgradePrompts,
showPairing,
startingTrial,
status,
@@ -2,14 +2,9 @@ import { createEffect, createSignal, onMount } from 'solid-js';
import { apiErrorFromResponse, apiFetch } from '@/utils/apiClient';
import { showSuccess, showWarning } from '@/utils/toast';
import type { SelectedResource } from '@/components/Settings/ResourcePicker';
import {
hasFeature,
runtimeCapabilitiesLoaded,
} from '@/stores/license';
import {
canOfferCommercialTrial,
getUpgradeActionDestination,
} from '@/stores/licenseCommercial';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { canOfferCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed } from '@/utils/upgradeMetrics';
import {
@@ -27,9 +22,7 @@ import {
getReportingRangeStart,
type ReportingRangeValue,
} from '@/components/Settings/reportingPanelModel';
import {
buildVMInventoryExportRequest,
} from '@/components/Settings/reportingInventoryExportModel';
import { buildVMInventoryExportRequest } from '@/components/Settings/reportingInventoryExportModel';
import {
buildLegacyReportingCatalogFallback,
buildReportingCatalogRequest,
@@ -52,16 +45,13 @@ export const useReportingPanelState = () => {
const [title, setTitle] = createSignal('');
const [startingTrial, setStartingTrial] = createSignal(false);
const reportingFeatureId = () => reportingCatalog()?.id ?? '';
const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts();
const isLocked = () =>
runtimeCapabilitiesLoaded() &&
reportingFeatureId() !== '' &&
!hasFeature(reportingFeatureId());
const canStartTrial = () => canOfferCommercialTrial();
runtimeCapabilitiesLoaded() && reportingFeatureId() !== '' && !hasFeature(reportingFeatureId());
const canStartTrial = () => showUpgradePrompts() && canOfferCommercialTrial();
const isReportingEnabled = () =>
runtimeCapabilitiesLoaded() &&
reportingFeatureId() !== '' &&
hasFeature(reportingFeatureId());
runtimeCapabilitiesLoaded() && reportingFeatureId() !== '' && hasFeature(reportingFeatureId());
const upgradeDestination = () =>
reportingFeatureId() === ''
? getUpgradeActionDestination('')
@@ -73,7 +63,7 @@ export const useReportingPanelState = () => {
createEffect((wasVisible: boolean) => {
const visible = isLocked();
if (visible && !wasVisible) {
if (showUpgradePrompts() && visible && !wasVisible) {
trackPaywallViewed(reportingFeatureId(), 'settings_reporting_panel');
}
return visible;
@@ -181,15 +171,18 @@ export const useReportingPanelState = () => {
const selectedFormat = format() ?? performanceReport.defaultFormat;
const selectedRange = range() ?? performanceReport.defaultRange;
const start = getReportingRangeStart(selectedRange, now, performanceReport);
const request = buildReportingRequest({
end: now.toISOString(),
format: selectedFormat,
metricType: metricType(),
now,
resources,
start: start.toISOString(),
title: title(),
}, performanceReport);
const request = buildReportingRequest(
{
end: now.toISOString(),
format: selectedFormat,
metricType: metricType(),
now,
resources,
start: start.toISOString(),
title: title(),
},
performanceReport,
);
const response = await apiFetch(request.request.url, request.request.init);
if (!response.ok) {
@@ -262,6 +255,7 @@ export const useReportingPanelState = () => {
setSelectedResources,
setTitle,
startingTrial,
showUpgradePrompts,
title,
upgradeDestination,
};
@@ -2,14 +2,9 @@ import { createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { createStore } from 'solid-js/store';
import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import {
hasFeature,
runtimeCapabilitiesLoaded,
} from '@/stores/license';
import {
canOfferCommercialTrial,
getUpgradeActionDestination,
} from '@/stores/licenseCommercial';
import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license';
import { canOfferCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { loadRuntimeCapabilities } from '@/stores/license';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
import {
@@ -71,7 +66,8 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
const hasAdvancedSSO = createMemo(() => hasFeature('advanced_sso'));
const canManage = () => props.canManage !== false;
const canStartTrial = () => canOfferCommercialTrial();
const showUpgradePrompts = () => !presentationPolicyHidesUpgradePrompts();
const canStartTrial = () => showUpgradePrompts() && canOfferCommercialTrial();
const handleStartTrial = async () => {
if (startingTrial()) {
@@ -89,7 +85,8 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
};
createEffect((wasBannerVisible) => {
const isBannerVisible = runtimeCapabilitiesLoaded() && !hasAdvancedSSO() && !loading();
const isBannerVisible =
showUpgradePrompts() && runtimeCapabilitiesLoaded() && !hasAdvancedSSO() && !loading();
if (isBannerVisible && !wasBannerVisible) {
trackPaywallViewed('advanced_sso', 'settings_sso_providers_banner');
}
@@ -97,7 +94,7 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
}, false);
createEffect((wasUpsellVisible) => {
const isUpsellVisible = showSamlUpsell();
const isUpsellVisible = showUpgradePrompts() && showSamlUpsell();
if (isUpsellVisible && !wasUpsellVisible) {
trackPaywallViewed('advanced_sso', 'settings_sso_providers_add_saml_gate');
}
@@ -137,6 +134,9 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
if (!canManage()) {
return;
}
if (type === 'saml' && !hasAdvancedSSO() && !showUpgradePrompts()) {
return;
}
setEditingProvider(null);
setForm(createEmptyProviderForm());
setForm('type', type);
@@ -175,7 +175,9 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
const { apiFetch } = await import('@/utils/apiClient');
const isEdit = Boolean(editingProvider());
const response = await apiFetch(
isEdit ? `/api/security/sso/providers/${editingProvider()!.id}` : '/api/security/sso/providers',
isEdit
? `/api/security/sso/providers/${editingProvider()!.id}`
: '/api/security/sso/providers',
{
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -341,6 +343,7 @@ export const useSSOProvidersState = (props: SSOProvidersPanelProps) => {
publicUrl,
showSamlUpsell,
setShowSamlUpsell,
showUpgradePrompts,
testing,
testResult,
setTestResult,
@@ -11,7 +11,9 @@ interface HistoryChartOverlayProps {
export const HistoryChartOverlay: Component<HistoryChartOverlayProps> = (props) => {
return (
<>
<Show when={!props.chart.loading() && props.chart.data().length === 0 && !props.chart.error()}>
<Show
when={!props.chart.loading() && props.chart.data().length === 0 && !props.chart.error()}
>
<div class="absolute inset-0 flex items-center justify-center bg-surface">
<div class="text-center">
<div class="text-slate-400 mb-2">
@@ -69,12 +71,15 @@ export const HistoryChartOverlay: Component<HistoryChartOverlayProps> = (props)
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3 class="text-lg font-bold text-base-content mb-1">{props.chart.lockDays()}-Day History</h3>
<h3 class="text-lg font-bold text-base-content mb-1">
{props.chart.lockDays()}-Day History
</h3>
<Show
when={!presentationPolicyHidesUpgradePrompts()}
fallback={
<p class="text-sm text-muted text-center max-w-[220px] mb-4">
Historical data beyond {props.chart.lockDays()} days is hidden in this demo.
Historical data beyond {props.chart.lockDays()} days is not enabled on this
instance.
</p>
}
>
@@ -85,7 +90,9 @@ export const HistoryChartOverlay: Component<HistoryChartOverlayProps> = (props)
<div class="flex flex-col items-center gap-2">
<UpgradeLink
destination={props.chart.getUpgradeActionDestination('long_term_metrics')}
onClick={() => props.chart.trackUpgradeClicked('history_chart', 'long_term_metrics')}
onClick={() =>
props.chart.trackUpgradeClicked('history_chart', 'long_term_metrics')
}
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-md shadow-sm transition-colors"
>
Unlock {props.chart.lockTierLabel()} Features
@@ -76,7 +76,9 @@ describe('HistoryChart', () => {
expect(historyChartStateSource).toContain('export function useHistoryChartState');
expect(historyChartStateSource).toContain('HISTORY_CHART_RANGES');
expect(historyChartStateSource).toContain("'mock_synthetic' | null");
expect(historyChartStateSource).toContain('const canStartTrial = createMemo(() => canStartCommercialTrial());');
expect(historyChartStateSource).toContain(
'const canStartTrial = createMemo(() => canStartCommercialTrial());',
);
expect(historyChartStateSource).toContain('runStartProTrialAction({');
expect(historyChartStateSource).not.toContain('startProTrial()');
expect(historyChartStateSource).not.toContain('getTrialAlreadyUsedMessage()');
@@ -95,7 +97,7 @@ describe('HistoryChart', () => {
expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
expect(historyChartOverlaySource).toContain('Unlock {props.chart.lockTierLabel()} Features');
expect(historyChartOverlaySource).toContain('presentationPolicyHidesUpgradePrompts');
expect(historyChartOverlaySource).toContain('Historical data beyond {props.chart.lockDays()} days is hidden in this demo.');
expect(historyChartOverlaySource).toContain('is not enabled on this');
expect(historyChartOverlaySource).not.toContain('ChartsAPI.getMetricsHistory');
expect(historyChartOverlaySource).not.toContain('setupCanvasDPR');
@@ -109,9 +111,7 @@ describe('HistoryChart', () => {
});
it('renders the default history label', () => {
render(() => (
<HistoryChart resourceType="agent" resourceId="node-1" metric="cpu" />
));
render(() => <HistoryChart resourceType="agent" resourceId="node-1" metric="cpu" />);
expect(screen.getByText('History')).toBeInTheDocument();
});
@@ -10,20 +10,21 @@ import { getPublicPricingUrl } from '@/utils/pricingHandoff';
const {
presentationPolicyHidesCommercialSurfacesMock,
presentationPolicyHidesUpgradePromptsMock,
getUpgradeActionDestinationMock,
getUpgradeActionUrlOrFallbackMock,
commercialPostureMock,
isUpsellSnoozedMock,
snoozeUpsellMock,
} =
vi.hoisted(() => ({
presentationPolicyHidesCommercialSurfacesMock: vi.fn(),
getUpgradeActionDestinationMock: vi.fn(),
getUpgradeActionUrlOrFallbackMock: vi.fn(),
commercialPostureMock: vi.fn(),
isUpsellSnoozedMock: vi.fn(),
snoozeUpsellMock: vi.fn(),
}));
} = vi.hoisted(() => ({
presentationPolicyHidesCommercialSurfacesMock: vi.fn(),
presentationPolicyHidesUpgradePromptsMock: vi.fn(),
getUpgradeActionDestinationMock: vi.fn(),
getUpgradeActionUrlOrFallbackMock: vi.fn(),
commercialPostureMock: vi.fn(),
isUpsellSnoozedMock: vi.fn(),
snoozeUpsellMock: vi.fn(),
}));
vi.mock('@/stores/licenseCommercial', () => ({
commercialTrialDaysRemaining: () => commercialPostureMock()?.trial_days_remaining ?? null,
@@ -34,8 +35,8 @@ vi.mock('@/stores/licenseCommercial', () => ({
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesCommercialSurfaces: () =>
presentationPolicyHidesCommercialSurfacesMock(),
presentationPolicyHidesCommercialSurfaces: () => presentationPolicyHidesCommercialSurfacesMock(),
presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(),
}));
vi.mock('@/utils/snooze', () => ({
@@ -44,15 +45,17 @@ vi.mock('@/utils/snooze', () => ({
}));
describe('TrialBanner', () => {
const renderBanner = () => render(() => (
<Router>
<Route path="/" component={() => <TrialBanner />} />
</Router>
));
const renderBanner = () =>
render(() => (
<Router>
<Route path="/" component={() => <TrialBanner />} />
</Router>
));
beforeEach(() => {
cleanup();
presentationPolicyHidesCommercialSurfacesMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
getUpgradeActionDestinationMock.mockReset();
getUpgradeActionUrlOrFallbackMock.mockReset();
commercialPostureMock.mockReset();
@@ -63,6 +66,7 @@ describe('TrialBanner', () => {
external: true,
});
presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(false);
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(false);
getUpgradeActionUrlOrFallbackMock.mockReturnValue(getPublicPricingUrl('trial_banner'));
isUpsellSnoozedMock.mockReturnValue(false);
});
@@ -85,6 +89,7 @@ describe('TrialBanner', () => {
expect(trialBannerStateSource).toContain('createMemo');
expect(trialBannerStateSource).not.toContain('loadCommercialPosture');
expect(trialBannerStateSource).toContain('presentationPolicyHidesCommercialSurfaces');
expect(trialBannerStateSource).toContain('presentationPolicyHidesUpgradePrompts');
expect(trialBannerStateSource).toContain('isCommercialTrialActive');
expect(trialBannerStateSource).toContain('commercialTrialDaysRemaining');
expect(trialBannerStateSource).toContain('getUpgradeActionDestination');
@@ -136,6 +141,20 @@ describe('TrialBanner', () => {
expect(screen.queryByRole('status')).toBeNull();
});
it('hides upgrade actions when self-hosted upgrade prompts are hidden', async () => {
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
commercialPostureMock.mockReturnValue({
subscription_state: 'trial',
trial_days_remaining: 2,
});
renderBanner();
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.queryByText('Upgrade')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Snooze 7d' })).not.toBeInTheDocument();
});
it('snoozes and hides the action row', async () => {
commercialPostureMock.mockReturnValue({
subscription_state: 'trial',
@@ -1,20 +1,8 @@
import {
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
} from 'solid-js';
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { ChartsAPI, type HistoryTimeRange } from '@/api/charts';
import {
isRangeLocked,
loadRuntimeCapabilities,
maxHistoryDays,
} from '@/stores/license';
import {
canStartCommercialTrial,
getUpgradeActionDestination,
} from '@/stores/licenseCommercial';
import { isRangeLocked, loadRuntimeCapabilities, maxHistoryDays } from '@/stores/license';
import { canStartCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { calculateOptimalPoints } from '@/utils/downsample';
import { setupCanvasDPR } from '@/utils/canvasRenderQueue';
import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics';
@@ -45,7 +33,9 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
const [data, setData] = createSignal(props.data ?? []);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | 'mock_synthetic' | null>(null);
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | 'mock_synthetic' | null>(
null,
);
const [maxPoints, setMaxPoints] = createSignal<number | null>(null);
const [refreshTick, setRefreshTick] = createSignal(0);
const [hasLoadedOnce, setHasLoadedOnce] = createSignal(false);
@@ -107,7 +97,7 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
createEffect((wasVisible) => {
const visible = isLocked() && !props.hideLock;
if (visible && !wasVisible) {
if (!presentationPolicyHidesUpgradePrompts() && visible && !wasVisible) {
trackPaywallViewed('long_term_metrics', 'history_chart');
}
return visible;
@@ -271,8 +261,7 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar
const labelCount = 4;
for (let index = 0; index < labelCount; index++) {
const timestamp =
points[0].timestamp + (geometry.timeSpan * index) / (labelCount - 1);
const timestamp = points[0].timestamp + (geometry.timeSpan * index) / (labelCount - 1);
const x = geometry.getX(timestamp);
ctx.fillText(formatHistoryChartTimeLabel(timestamp, range()), x, height - 2);
}
@@ -4,7 +4,10 @@ import {
getUpgradeActionDestination,
isCommercialTrialActive,
} from '@/stores/licenseCommercial';
import { presentationPolicyHidesCommercialSurfaces } from '@/stores/sessionPresentationPolicy';
import {
presentationPolicyHidesCommercialSurfaces,
presentationPolicyHidesUpgradePrompts,
} from '@/stores/sessionPresentationPolicy';
import { isUpsellSnoozed, snoozeUpsell } from '@/utils/snooze';
import {
getTrialBannerToneClass,
@@ -17,9 +20,7 @@ export function useTrialBannerState() {
const [snoozed, setSnoozed] = createSignal(isUpsellSnoozed(TRIAL_BANNER_SNOOZE_KEY));
const isTrial = createMemo(
() =>
!presentationPolicyHidesCommercialSurfaces() &&
isCommercialTrialActive(),
() => !presentationPolicyHidesCommercialSurfaces() && isCommercialTrialActive(),
);
const daysRemaining = createMemo(() =>
normalizeTrialBannerDaysRemaining(commercialTrialDaysRemaining()),
@@ -38,7 +39,10 @@ export function useTrialBannerState() {
daysRemaining,
handleSnooze,
isTrial,
showActions: () => !presentationPolicyHidesCommercialSurfaces() && !snoozed(),
showActions: () =>
!presentationPolicyHidesCommercialSurfaces() &&
!presentationPolicyHidesUpgradePrompts() &&
!snoozed(),
toneClass,
upgradeDestination,
};
@@ -3,7 +3,7 @@ import patrolIntelligenceBannersSource from '../PatrolIntelligenceBanners.tsx?ra
import patrolIntelligenceHeaderSource from '../PatrolIntelligenceHeader.tsx?raw';
describe('patrol commercial boundary', () => {
it('suppresses patrol upgrade surfaces in demo mode', () => {
it('suppresses patrol upgrade surfaces when upgrade prompts are hidden', () => {
expect(patrolIntelligenceBannersSource).toContain('presentationPolicyHidesUpgradePrompts');
expect(patrolIntelligenceBannersSource).toContain(
'!presentationPolicyHidesUpgradePrompts() && state.licenseRequired()',
@@ -1,10 +1,4 @@
import {
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
} from 'solid-js';
import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import { AIAPI } from '@/api/ai';
import {
getPatrolAutonomySettings,
@@ -29,14 +23,9 @@ import { notificationStore } from '@/stores/notifications';
import { hasTriggeringAlert } from '@/utils/findingAlertIdentity';
import { usePatrolStream } from '@/hooks/usePatrolStream';
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
import {
hasFeature,
loadRuntimeCapabilities,
} from '@/stores/license';
import {
canStartCommercialTrial,
getUpgradeActionDestination,
} from '@/stores/licenseCommercial';
import { hasFeature, loadRuntimeCapabilities } from '@/stores/license';
import { canStartCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import type { AISettings } from '@/types/ai';
import { getCanonicalScopeResourceIds } from '@/utils/patrolFormat';
import { buildPatrolInvestigationContextSummary } from './patrolInvestigationContextModel';
@@ -381,7 +370,7 @@ export function usePatrolIntelligenceState() {
createEffect((wasAutoFixLocked) => {
const isAutoFixLocked = autoFixLocked();
if (isAutoFixLocked && !wasAutoFixLocked) {
if (!presentationPolicyHidesUpgradePrompts() && isAutoFixLocked && !wasAutoFixLocked) {
trackPaywallViewed('ai_autofix', 'ai_intelligence');
}
return isAutoFixLocked;
@@ -389,7 +378,11 @@ export function usePatrolIntelligenceState() {
createEffect((wasAlertAnalysisLocked) => {
const isAlertAnalysisLocked = alertAnalysisLocked();
if (isAlertAnalysisLocked && !wasAlertAnalysisLocked) {
if (
!presentationPolicyHidesUpgradePrompts() &&
isAlertAnalysisLocked &&
!wasAlertAnalysisLocked
) {
trackPaywallViewed('ai_alerts', 'ai_intelligence');
}
return isAlertAnalysisLocked;
@@ -397,7 +390,11 @@ export function usePatrolIntelligenceState() {
createEffect((wasLicenseBannerVisible) => {
const isLicenseBannerVisible = licenseRequired() && !showBlockedBanner();
if (isLicenseBannerVisible && !wasLicenseBannerVisible) {
if (
!presentationPolicyHidesUpgradePrompts() &&
isLicenseBannerVisible &&
!wasLicenseBannerVisible
) {
trackPaywallViewed('ai_autofix', 'ai_intelligence_banner');
}
return isLicenseBannerVisible;
@@ -110,6 +110,7 @@ const getUpgradeActionDestinationMock = vi.fn();
const getUpgradeActionUrlOrFallbackMock = vi.fn();
const trackPaywallViewedMock = vi.fn();
const trackUpgradeClickedMock = vi.fn();
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
const notificationSuccessMock = vi.fn();
const notificationErrorMock = vi.fn();
@@ -155,6 +156,10 @@ vi.mock('@/utils/upgradeMetrics', () => ({
trackUpgradeClicked: (...args: unknown[]) => trackUpgradeClickedMock(...args),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(),
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: {
success: (...args: unknown[]) => notificationSuccessMock(...args),
@@ -341,6 +346,7 @@ describe('AIIntelligence entitlement gating', () => {
getUpgradeActionUrlOrFallbackMock.mockReset();
trackPaywallViewedMock.mockReset();
trackUpgradeClickedMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
notificationSuccessMock.mockReset();
notificationErrorMock.mockReset();
findingsPanelState.latestProps = null;
@@ -393,6 +399,7 @@ describe('AIIntelligence entitlement gating', () => {
getUpgradeActionUrlOrFallbackMock.mockImplementation((feature?: string) =>
getPublicPricingUrl(feature),
);
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(false);
getCorrelationsMock.mockResolvedValue({
correlations: [],
count: 0,
@@ -566,6 +573,33 @@ describe('AIIntelligence entitlement gating', () => {
});
});
it('locks paid patrol controls without upgrade prompts in default self-hosted mode', async () => {
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
getPatrolStatusMock.mockResolvedValue(
defaultPatrolStatus({
license_required: true,
}),
);
render(() => <AIIntelligence />);
await waitFor(() => {
expect(loadLicenseStatusMock).toHaveBeenCalled();
expect(getPatrolStatusMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: 'Configure Patrol' }));
await screen.findByRole('button', { name: 'Investigate' });
expect(screen.getByRole('button', { name: 'Investigate' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Auto-fix' })).toBeDisabled();
expect(screen.queryByRole('link', { name: 'Upgrade to Pro' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Upgrade' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /start free trial/i })).not.toBeInTheDocument();
expect(trackPaywallViewedMock).not.toHaveBeenCalled();
});
it('unlocks paid patrol controls when the entitlement grants the features', async () => {
hasFeatureMock.mockReturnValue(true);
licenseStatusMock.mockReturnValue({ subscription_state: 'active' });
@@ -7,6 +7,7 @@ import {
} from '@/api/license';
import {
presentationPolicyHidesCommercialSurfaces,
presentationPolicyHidesUpgradePrompts,
sessionPresentationPolicyResolved,
} from '@/stores/sessionPresentationPolicy';
import { getUpgradeFallbackDestination } from '@/utils/pricingHandoff';
@@ -44,6 +45,7 @@ import {
vi.mock('@/api/license');
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesCommercialSurfaces: vi.fn(() => false),
presentationPolicyHidesUpgradePrompts: vi.fn(() => false),
sessionPresentationPolicyResolved: vi.fn(() => true),
}));
vi.mock('@/stores/events', () => ({
@@ -96,6 +98,7 @@ describe('license stores', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(presentationPolicyHidesCommercialSurfaces).mockReturnValue(false);
vi.mocked(presentationPolicyHidesUpgradePrompts).mockReturnValue(false);
vi.mocked(sessionPresentationPolicyResolved).mockReturnValue(true);
});
@@ -358,6 +361,17 @@ describe('license stores', () => {
expect(LicenseAPI.startTrial).not.toHaveBeenCalled();
});
it('startProTrial fails closed when upgrade prompts are hidden', async () => {
vi.mocked(presentationPolicyHidesUpgradePrompts).mockReturnValue(true);
await expect(startProTrial()).rejects.toMatchObject({
message: 'Trial activation unavailable under the current presentation policy',
status: 404,
code: 'presentation_policy_unavailable',
});
expect(LicenseAPI.startTrial).not.toHaveBeenCalled();
});
it('startProTrial preserves backend error details for trial-not-available responses', async () => {
vi.mocked(LicenseAPI.startTrial).mockResolvedValue({
ok: false,
@@ -556,7 +570,9 @@ describe('license stores', () => {
it('routes generic upgrade fallbacks to Pulse Account', async () => {
vi.mocked(LicenseAPI.getCommercialPosture).mockResolvedValue(mockFreeCommercialPosture);
await loadCommercialPosture(true);
expect(getUpgradeActionUrlOrFallback('upgrade')).toBe(getUpgradeFallbackDestination('upgrade'));
expect(getUpgradeActionUrlOrFallback('upgrade')).toBe(
getUpgradeFallbackDestination('upgrade'),
);
});
it('prefers a specific monitored-system upgrade action when provided', async () => {
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
presentationPolicyHidesUpgradePrompts,
syncSessionPresentationPolicy,
} from '@/stores/sessionPresentationPolicy';
const resetPresentationPolicy = () => {
syncSessionPresentationPolicy(null);
};
describe('session presentation policy', () => {
afterEach(() => {
resetPresentationPolicy();
});
it('defaults unresolved self-hosted sessions to hiding upgrade prompts', () => {
const policy = syncSessionPresentationPolicy(null);
expect(policy.hideUpgrade).toBe(true);
expect(presentationPolicyHidesUpgradePrompts()).toBe(true);
});
it('honors an explicit hosted policy that enables upgrade prompts', () => {
const policy = syncSessionPresentationPolicy({
presentationPolicy: {
demoMode: false,
readOnly: false,
hideCommercial: false,
hideUpgrade: false,
},
sessionCapabilities: {
demoMode: false,
},
});
expect(policy.hideUpgrade).toBe(false);
expect(presentationPolicyHidesUpgradePrompts()).toBe(false);
});
it('forces upgrade prompts hidden in demo mode', () => {
const policy = syncSessionPresentationPolicy({
presentationPolicy: {
demoMode: true,
readOnly: false,
hideCommercial: false,
hideUpgrade: false,
},
sessionCapabilities: {
demoMode: false,
},
});
expect(policy.hideUpgrade).toBe(true);
expect(presentationPolicyHidesUpgradePrompts()).toBe(true);
});
});
@@ -10,14 +10,12 @@ import {
getUpgradeFallbackDestination,
isSelfHostedPurchaseStartDestination,
} from '@/utils/pricingHandoff';
import {
resolveUpgradeDestination,
type UpgradeDestination,
} from '@/utils/upgradeNavigation';
import { resolveUpgradeDestination, type UpgradeDestination } from '@/utils/upgradeNavigation';
import { loadRuntimeCapabilities } from '@/stores/license';
import { loadLicenseEntitlements } from '@/stores/licenseEntitlements';
import {
presentationPolicyHidesCommercialSurfaces,
presentationPolicyHidesUpgradePrompts,
sessionPresentationPolicyResolved,
} from '@/stores/sessionPresentationPolicy';
@@ -142,7 +140,11 @@ export async function loadCommercialPosture(force = false): Promise<void> {
* Start a Pro trial for the current org, then refresh both runtime and commercial state.
*/
export async function startProTrial(): Promise<StartProTrialResult> {
if (!sessionPresentationPolicyResolved() || presentationPolicyHidesCommercialSurfaces()) {
if (
!sessionPresentationPolicyResolved() ||
presentationPolicyHidesCommercialSurfaces() ||
presentationPolicyHidesUpgradePrompts()
) {
const err = new Error(
'Trial activation unavailable under the current presentation policy',
) as TrialStartRequestError;
@@ -214,6 +216,7 @@ export const isPro = createRoot(() =>
* current posture explicitly denies trial eligibility.
*/
export function canOfferCommercialTrial(): boolean {
if (presentationPolicyHidesUpgradePrompts()) return false;
return commercialPostureState()?.trial_eligible !== false;
}
@@ -222,6 +225,7 @@ export function canOfferCommercialTrial(): boolean {
* commercial posture is loaded and not already active/trial-backed.
*/
export function canStartCommercialTrial(): boolean {
if (presentationPolicyHidesUpgradePrompts()) return false;
const current = commercialPostureState();
if (!current) return false;
if (current.subscription_state === 'active' || current.subscription_state === 'trial') {
@@ -262,7 +266,10 @@ export function getFirstUpgradeActionUrl(): string | undefined {
export function getUpgradeActionDestination(key: string): UpgradeDestination {
const productOwnedHref = key ? getUpgradeFallbackDestination(key) : undefined;
const href =
productOwnedHref || getUpgradeActionUrl(key) || getFirstUpgradeActionUrl() || getUpgradeFallbackDestination(key);
productOwnedHref ||
getUpgradeActionUrl(key) ||
getFirstUpgradeActionUrl() ||
getUpgradeFallbackDestination(key);
if (isSelfHostedPurchaseStartDestination(href)) {
return resolveUpgradeDestination(href, {
@@ -10,26 +10,32 @@ const DEFAULT_SESSION_PRESENTATION_POLICY: SecurityStatusPresentationPolicy = {
demoMode: false,
readOnly: false,
hideCommercial: false,
hideUpgrade: false,
hideUpgrade: true,
};
const [sessionPresentationPolicy, setSessionPresentationPolicy] =
createSignal<SecurityStatusPresentationPolicy>({
...DEFAULT_SESSION_PRESENTATION_POLICY,
});
const [sessionPresentationPolicyResolved, setSessionPresentationPolicyResolved] = createSignal(false);
const [sessionPresentationPolicyResolved, setSessionPresentationPolicyResolved] =
createSignal(false);
function normalizeSessionPresentationPolicy(
policy?: Partial<SecurityStatusPresentationPolicy> | null,
sessionCapabilities?: Partial<SecurityStatusSessionCapabilities> | null,
): SecurityStatusPresentationPolicy {
const demoMode = policy?.demoMode === true || sessionCapabilities?.demoMode === true;
const hideUpgrade =
demoMode ||
(typeof policy?.hideUpgrade === 'boolean'
? policy.hideUpgrade
: DEFAULT_SESSION_PRESENTATION_POLICY.hideUpgrade);
return {
...DEFAULT_SESSION_PRESENTATION_POLICY,
demoMode,
readOnly: policy?.readOnly === true || demoMode,
hideCommercial: policy?.hideCommercial === true || demoMode,
hideUpgrade: policy?.hideUpgrade === true || demoMode,
hideUpgrade,
};
}
@@ -3155,9 +3155,7 @@ describe('frontend resource type boundaries', () => {
expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.');
expect(historyChartOverlaySource).toContain('presentationPolicyHidesUpgradePrompts');
expect(historyChartOverlaySource).toContain('Unlock {props.chart.lockTierLabel()} Features');
expect(historyChartOverlaySource).toContain(
'Historical data beyond {props.chart.lockDays()} days is hidden in this demo.',
);
expect(historyChartOverlaySource).toContain('is not enabled on this');
expect(historyChartTooltipSource).toContain('formatHistoryChartTooltipValue');
expect(containerUpdateBadgeSource).toContain('useContainerUpdateButtonState');
expect(containerUpdateBadgeSource).toContain('getUpdateButtonClass');
+2 -2
View File
@@ -8715,7 +8715,7 @@ func TestContract_SecurityStatusIncludesSessionCapabilitiesDemoMode(t *testing.T
}
}
func TestContract_SecurityStatusPresentationPolicyDefaultsClosedOutsideDemo(t *testing.T) {
func TestContract_SecurityStatusPresentationPolicyDefaultsHideUpgradeOutsideHosted(t *testing.T) {
cfg := newTestConfigWithTokens(t)
cfg.DemoMode = false
@@ -8741,7 +8741,7 @@ func TestContract_SecurityStatusPresentationPolicyDefaultsClosedOutsideDemo(t *t
"demoMode": false,
"readOnly": false,
"hideCommercial": false,
"hideUpgrade": false,
"hideUpgrade": true,
} {
if got, _ := presentationPolicy[key].(bool); got != want {
t.Fatalf("presentationPolicy.%s = %v, want %v", key, presentationPolicy[key], want)
+2 -1
View File
@@ -254,10 +254,11 @@ func (r *Router) securityStatusSessionCapabilities(ctx context.Context) security
func (r *Router) securityStatusPresentationPolicy() securityStatusPresentationPolicy {
demoMode := r != nil && r.config != nil && r.config.DemoMode
hideUpgrade := demoMode || r == nil || !r.hostedMode
return securityStatusPresentationPolicy{
DemoMode: demoMode,
ReadOnly: demoMode,
HideCommercial: demoMode,
HideUpgrade: demoMode,
HideUpgrade: hideUpgrade,
}
}
+11 -4
View File
@@ -309,13 +309,13 @@ func (c *CPConfig) validate() error {
if strings.TrimSpace(c.StripeAPIKey) != "" && strings.TrimSpace(c.TrialActivationPrivateKey) == "" {
return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when STRIPE_API_KEY is configured")
}
if err := validateCloudStripePriceID("CP_TRIAL_SIGNUP_PRICE_ID", c.TrialSignupPriceID, "cloud_starter"); err != nil {
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_TRIAL_SIGNUP_PRICE_ID", c.TrialSignupPriceID, "cloud_starter"); err != nil {
return err
}
if err := validateCloudStripePriceID("CP_CLOUD_POWER_PRICE_ID", c.CloudPowerPriceID, "cloud_power"); err != nil {
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_CLOUD_POWER_PRICE_ID", c.CloudPowerPriceID, "cloud_power"); err != nil {
return err
}
if err := validateCloudStripePriceID("CP_CLOUD_MAX_PRICE_ID", c.CloudMaxPriceID, "cloud_max"); err != nil {
if err := validateCloudStripePriceID(c.Environment, c.StripeAPIKey, "CP_CLOUD_MAX_PRICE_ID", c.CloudMaxPriceID, "cloud_max"); err != nil {
return err
}
if strings.TrimSpace(c.LicenseServerURL) == "" && strings.TrimSpace(c.LicenseAdminToken) != "" {
@@ -348,12 +348,19 @@ func (c *CPConfig) validate() error {
return nil
}
func validateCloudStripePriceID(envName, priceID, wantPlanVersion string) error {
func validateCloudStripePriceID(environment, stripeAPIKey, envName, priceID, wantPlanVersion string) error {
trimmed := strings.TrimSpace(priceID)
if trimmed == "" {
return nil
}
if environment != "production" && stripeSecretKeyMode(stripeAPIKey) == "test" {
if strings.HasPrefix(trimmed, "price_") {
return nil
}
return fmt.Errorf("%s must be a Stripe price id (price_...) in %s test mode, got %q", envName, environment, trimmed)
}
planVersion, ok := pkglicensing.PlanVersionForPriceID(trimmed)
if !ok {
return fmt.Errorf("%s must map to the canonical %s Stripe price, got unknown price id %q", envName, wantPlanVersion, trimmed)
+37 -2
View File
@@ -311,10 +311,11 @@ func TestLoadConfig_EmailProviderRequired(t *testing.T) {
}
}
func TestLoadConfig_RejectsNonCloudTrialSignupPriceID(t *testing.T) {
func TestLoadConfig_RejectsNonCloudTrialSignupPriceIDInProductionCatalog(t *testing.T) {
setRequiredCPEnv(t)
setTrialSigningEnv(t)
t.Setenv("STRIPE_API_KEY", "sk_test_123")
t.Setenv("CP_ENV", "production")
t.Setenv("STRIPE_API_KEY", "sk_live_123")
t.Setenv("CP_TRIAL_SIGNUP_PRICE_ID", "price_1T47OVBrHBocJIGHg4sMHMV7")
_, err := LoadConfig()
@@ -326,6 +327,40 @@ func TestLoadConfig_RejectsNonCloudTrialSignupPriceID(t *testing.T) {
}
}
func TestLoadConfig_AllowsUnmappedTestPriceIDsInStaging(t *testing.T) {
setRequiredCPEnv(t)
setTrialSigningEnv(t)
t.Setenv("CP_ENV", "staging")
t.Setenv("STRIPE_API_KEY", "sk_test_123")
t.Setenv("CP_PUBLIC_CLOUD_SIGNUP_ENABLED", "true")
t.Setenv("CP_TRIAL_SIGNUP_PRICE_ID", "price_1SandboxTrial123")
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.TrialSignupPriceID != "price_1SandboxTrial123" {
t.Fatalf("TrialSignupPriceID=%q want staging sandbox price", cfg.TrialSignupPriceID)
}
}
func TestLoadConfig_RejectsInvalidTestPriceIDInStaging(t *testing.T) {
setRequiredCPEnv(t)
setTrialSigningEnv(t)
t.Setenv("CP_ENV", "staging")
t.Setenv("STRIPE_API_KEY", "sk_test_123")
t.Setenv("CP_PUBLIC_CLOUD_SIGNUP_ENABLED", "true")
t.Setenv("CP_TRIAL_SIGNUP_PRICE_ID", "not_a_price")
_, err := LoadConfig()
if err == nil {
t.Fatal("expected error for invalid staging CP_TRIAL_SIGNUP_PRICE_ID")
}
if !strings.Contains(err.Error(), "must be a Stripe price id") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadConfig_AcceptsCanonicalCloudSignupPriceIDs(t *testing.T) {
setRequiredCPEnv(t)
setTrialSigningEnv(t)
@@ -247,7 +247,7 @@ func validatePublicCloudSignupPriceID(tier cloudTier, priceID string) error {
if wantPlanVersion == "" {
return fmt.Errorf("unsupported cloud tier %q", tier)
}
if err := validateCloudStripePriceID("public cloud signup price", priceID, wantPlanVersion); err != nil {
if err := validateCloudStripePriceID("production", "", "public cloud signup price", priceID, wantPlanVersion); err != nil {
return err
}
return nil
+14
View File
@@ -19,6 +19,7 @@ import (
const webhookBodyLimit = 1024 * 1024 // 1 MiB
const checkoutProvisioningTimeout = 2 * time.Minute
const selfHostedProTrialSignupSource = "pulse_pro_trial"
// WebhookHandler handles incoming Stripe webhook events.
type WebhookHandler struct {
@@ -137,6 +138,12 @@ func (h *WebhookHandler) handleEvent(r *http.Request, event *stripelib.Event) er
if err := json.Unmarshal(event.Data.Raw, &session); err != nil {
return fmt.Errorf("decode checkout.session: %w", err)
}
if isSelfHostedProTrialCheckout(session) {
log.Info().
Str("session_id", strings.TrimSpace(session.ID)).
Msg("Stripe webhook ignored self-hosted Pro trial checkout; activation is owned by trial signup completion")
return nil
}
return h.provisioner.HandleCheckout(ctx, session)
case "customer.subscription.updated":
@@ -169,6 +176,13 @@ func (h *WebhookHandler) handleEvent(r *http.Request, event *stripelib.Event) er
}
}
func isSelfHostedProTrialCheckout(session CheckoutSession) bool {
if session.Metadata == nil {
return false
}
return strings.EqualFold(strings.TrimSpace(session.Metadata["signup_source"]), selfHostedProTrialSignupSource)
}
func webhookEventContext(r *http.Request, eventType stripelib.EventType) (context.Context, context.CancelFunc) {
if eventType == stripelib.EventType("checkout.session.completed") {
return context.WithTimeout(context.Background(), checkoutProvisioningTimeout)
+52
View File
@@ -39,6 +39,58 @@ func TestWebhookRetriesFailedEventInsteadOfSkippingDuplicate(t *testing.T) {
}
}
func TestWebhookIgnoresSelfHostedProTrialCheckoutSessions(t *testing.T) {
reg := newTestRegistry(t)
tenantsDir := t.TempDir()
provisioner := NewProvisioner(reg, tenantsDir, nil, nil, "https://cloud.example.com", nil, "", false)
const secret = "whsec_test_secret"
handler := NewWebhookHandler(secret, provisioner)
eventJSON := `{
"id":"evt_self_hosted_trial_123",
"object":"event",
"type":"checkout.session.completed",
"data":{
"object":{
"id":"cs_test_self_hosted_pro_trial",
"mode":"subscription",
"customer":"cus_selfhost_trial",
"subscription":"sub_selfhost_trial",
"customer_email":"trial@business.example",
"customer_details":{"email":"trial@business.example"},
"metadata":{
"signup_source":"pulse_pro_trial",
"org_id":"default",
"return_url":"https://pulse.example.com/auth/trial-activate",
"instance_token":"tsi_test"
}
}
}
}`
req := signedWebhookRequest(t, secret, eventJSON)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("delivery status=%d, want=%d, body=%q", rec.Code, http.StatusOK, rec.Body.String())
}
tenants, err := reg.List()
if err != nil {
t.Fatalf("List tenants: %v", err)
}
if len(tenants) != 0 {
t.Fatalf("tenant count=%d, want 0 for self-hosted Pro trial checkout", len(tenants))
}
stripeAccount, err := reg.GetStripeAccountByCustomerID("cus_selfhost_trial")
if err != nil {
t.Fatalf("GetStripeAccountByCustomerID: %v", err)
}
if stripeAccount != nil {
t.Fatalf("Stripe account mapping was created for self-hosted Pro trial checkout: %#v", stripeAccount)
}
}
func TestWebhookEventContext_DetachesCheckoutFromRequestContext(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", nil)
ctx, cancelReq := context.WithCancel(req.Context())
@@ -10,15 +10,51 @@ const FREE_TRIAL_ELIGIBLE_ENTITLEMENTS = {
trial_eligible: true,
};
test.describe.serial('Self-hosted trial rate-limit UI', () => {
test('shows Retry-After guidance on Pro feature-gate trial CTAs', async ({ page }, testInfo) => {
const SELF_HOSTED_SECURITY_STATUS = {
hasAuthentication: true,
hideLocalLogin: false,
ssoProviders: [],
sessionCapabilities: {
demoMode: false,
},
presentationPolicy: {
demoMode: false,
readOnly: false,
hideCommercial: false,
hideUpgrade: true,
},
settingsCapabilities: {
apiAccessRead: true,
authenticationRead: true,
singleSignOnRead: true,
roles: true,
users: true,
auditLog: true,
auditWebhooksRead: true,
relayRead: true,
relayWrite: true,
},
};
test.describe.serial('Self-hosted paid prompt visibility', () => {
test('keeps paid-only navigation and trial CTAs out of the default self-hosted UI', async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith('mobile-'),
'Desktop-only feature-gate coverage',
'Desktop-only settings navigation coverage',
);
await ensureAuthenticated(page);
await page.route('**/api/security/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(SELF_HOSTED_SECURITY_STATUS),
});
});
await page.route('**/api/license/runtime-capabilities', async (route) => {
await route.fulfill({
status: 200,
@@ -26,6 +62,7 @@ test.describe.serial('Self-hosted trial rate-limit UI', () => {
body: JSON.stringify({
capabilities: [],
limits: [],
hosted_mode: false,
max_history_days: 7,
}),
});
@@ -47,31 +84,19 @@ test.describe.serial('Self-hosted trial rate-limit UI', () => {
});
});
await page.route('**/api/license/trial/start', async (route) => {
await route.fulfill({
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '120',
},
body: JSON.stringify({
code: 'trial_rate_limited',
error: 'Trial start rate limit exceeded',
details: {
retry_after_seconds: '45',
},
}),
});
});
await page.goto('/settings/security-roles');
await expect(page.getByRole('heading', { name: 'Roles' }).first()).toBeVisible();
const startTrialButton = page.getByRole('button', { name: /start free trial/i });
await expect(startTrialButton).toBeVisible();
await startTrialButton.click();
await expect(page.getByText('Try again in about 2 minutes')).toBeVisible();
await expect(page.getByText('Try again in about a minute')).toHaveCount(0);
await expect(
page.getByRole('heading', { name: 'Infrastructure', exact: true }),
).toBeVisible();
await expect(page.getByText('Custom Roles (Pro)')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Remote Access' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Roles' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Users' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Audit Log' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Audit Webhooks' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Plans & Activation' })).toHaveCount(0);
await expect(page.getByRole('link', { name: /upgrade to pro/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /start free trial/i })).toHaveCount(0);
});
});
@@ -0,0 +1,187 @@
import { expect, test } from '@playwright/test';
import { apiRequest, ensureAuthenticated } from './helpers';
import { completeStripeSandboxCheckout } from './stripe-sandbox';
import { preferredPlaywrightRouteBaseURL } from './runtime-defaults';
type EntitlementPayload = {
subscription_state?: string;
tier?: string;
trial_eligible?: boolean;
trial_days_remaining?: number;
valid?: boolean;
is_lifetime?: boolean;
};
type TrialStartPayload = {
code?: string;
details?: Record<string, string>;
};
type Tenant = {
id?: string;
email?: string;
state?: string;
};
type StripeSessionResponse = {
id: string;
subscription?: string | { id?: string };
};
function explicitCloudBaseURL(): string {
return String(process.env.PULSE_CLOUD_BASE_URL || '').trim();
}
function cloudBaseURL(): string {
return preferredPlaywrightRouteBaseURL(process.env, [
explicitCloudBaseURL(),
]);
}
function requiredEnv(name: string): string {
const value = String(process.env[name] || '').trim();
if (value === '') {
throw new Error(`Missing required env var: ${name}`);
}
return value;
}
function trialIdentity() {
const suffix = `${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
return {
name: `Pulse Trial E2E ${suffix}`,
email: `trial-${suffix}@customer-pulse-e2e.test`,
company: `Pulse Trial E2E ${suffix}`,
};
}
async function listControlPlaneTenants(
request: import('@playwright/test').APIRequestContext,
baseURL: string,
adminKey: string,
): Promise<Tenant[]> {
const response = await request.get(`${baseURL}/admin/tenants`, {
headers: {
'X-Admin-Key': adminKey,
Accept: 'application/json',
},
});
expect(response.ok(), `control-plane tenant list failed: HTTP ${response.status()}`).toBeTruthy();
const payload = (await response.json()) as { tenants?: Tenant[] };
return Array.isArray(payload.tenants) ? payload.tenants : [];
}
async function stripeRequest<T>(secretKey: string, path: string, method = 'GET') {
const response = await fetch(`https://api.stripe.com${path}`, {
method,
headers: {
Authorization: `Bearer ${secretKey}`,
Accept: 'application/json',
},
});
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (!response.ok) {
const message =
((payload.error as Record<string, unknown> | undefined)?.message as string | undefined) ||
`Stripe API error (${response.status})`;
throw new Error(message);
}
return payload as T;
}
test.describe.serial('Self-hosted Pro trial Stripe sandbox activation', () => {
test('completes checkout, returns to Pulse, and activates the local trial entitlement', async ({ page }, testInfo) => {
test.setTimeout(180_000);
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only Stripe checkout workflow coverage');
test.skip(
explicitCloudBaseURL() === '',
'Set PULSE_CLOUD_BASE_URL to run self-hosted Pro trial sandbox activation coverage.',
);
const requiredVars = ['PULSE_CP_ADMIN_KEY'] as const;
const missingVars = requiredVars.filter((name) => String(process.env[name] || '').trim() === '');
test.skip(missingVars.length > 0, `Missing required env var(s): ${missingVars.join(', ')}`);
const cpBaseURL = cloudBaseURL();
const cpAdminKey = requiredEnv('PULSE_CP_ADMIN_KEY');
const identity = trialIdentity();
const stripeSecretKey = String(process.env.PULSE_E2E_STRIPE_API_KEY || '').trim();
let subscriptionID = '';
try {
await ensureAuthenticated(page);
const preRes = await apiRequest(page, '/api/license/entitlements');
expect(preRes.ok(), `entitlements pre-check failed: HTTP ${preRes.status()}`).toBeTruthy();
const pre = (await preRes.json()) as EntitlementPayload;
test.skip(
pre.trial_eligible !== true,
`Skipping full trial activation because trial_eligible is ${String(pre.trial_eligible)} in this environment.`,
);
expect(pre.tier).toBe('free');
expect(pre.valid ?? false).toBe(false);
expect(pre.is_lifetime ?? false).toBe(false);
const startRes = await apiRequest(page, '/api/license/trial/start', {
method: 'POST',
});
expect(startRes.status(), `trial start failed: HTTP ${startRes.status()}`).toBe(409);
const startPayload = (await startRes.json()) as TrialStartPayload;
expect(startPayload.code).toBe('trial_signup_required');
const actionURL = startPayload.details?.action_url ?? '';
expect(actionURL).toContain('/start-pro-trial');
const parsedActionURL = new URL(actionURL);
expect(parsedActionURL.origin).toBe(new URL(cpBaseURL).origin);
expect(parsedActionURL.searchParams.get('return_url')).toContain('/auth/trial-activate');
expect(parsedActionURL.searchParams.get('instance_token') || '').toMatch(/^tsi1_/);
await page.goto(actionURL);
await expect(page.getByRole('heading', { name: /start your 14-day pro trial/i })).toBeVisible();
await page.locator('#name').fill(identity.name);
await page.locator('#email').fill(identity.email);
await page.locator('#company').fill(identity.company);
await page.getByRole('button', { name: /continue to secure trial setup/i }).click();
const checkout = await completeStripeSandboxCheckout(page, {
email: identity.email,
cardholderName: identity.name,
});
if (stripeSecretKey !== '' && checkout.checkoutSessionID) {
const session = await stripeRequest<StripeSessionResponse>(
stripeSecretKey,
`/v1/checkout/sessions/${encodeURIComponent(checkout.checkoutSessionID)}?expand[]=subscription`,
);
subscriptionID = typeof session.subscription === 'string'
? session.subscription.trim()
: (session.subscription?.id || '').trim();
}
await page.waitForURL(/\/trial-signup\/complete\?session_id=/, { timeout: 120_000 });
await expect(page.getByRole('heading', { name: /trial entitlement ready/i })).toBeVisible();
await page.getByRole('link', { name: /return to pulse/i }).click();
await page.waitForURL(/\/settings\/system-pro\?trial=activated/, { timeout: 120_000 });
const postRes = await apiRequest(page, '/api/license/entitlements');
expect(postRes.ok(), `entitlements post-activation failed: HTTP ${postRes.status()}`).toBeTruthy();
const post = (await postRes.json()) as EntitlementPayload;
expect(post.subscription_state).toBe('trial');
expect(post.trial_days_remaining ?? 0).toBeGreaterThan(0);
expect(post.trial_eligible).toBe(false);
const tenants = await listControlPlaneTenants(page.request, cpBaseURL, cpAdminKey);
const matchingTenant = tenants.find((tenant) => tenant.email === identity.email);
expect(
matchingTenant,
'self-hosted Pro trial checkout must not create a hosted Cloud tenant',
).toBeUndefined();
} finally {
if (stripeSecretKey !== '' && subscriptionID !== '') {
await stripeRequest(stripeSecretKey, `/v1/subscriptions/${encodeURIComponent(subscriptionID)}`, 'DELETE')
.catch((error) => {
console.warn(`Stripe sandbox subscription cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
});
}
}
});
});
@@ -195,6 +195,15 @@ export async function completeStripeSandboxCheckout(
if (hasLeftStripeCheckout(page)) {
return { checkoutSessionID };
}
const startedCardlessTrial = await clickFirstVisibleButton(page, /start trial|start free trial/i);
if (startedCardlessTrial) {
await page
.waitForURL((url) => !STRIPE_URL_PATTERN.test(String(url)), { timeout: 90_000 })
.catch(() => undefined);
if (hasLeftStripeCheckout(page)) {
return { checkoutSessionID };
}
}
throw new Error('Unable to find Stripe card number input');
}