mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Keep self-hosted paid surfaces opt-in
This commit is contained in:
+50
@@ -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.
|
||||
@@ -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 />);
|
||||
|
||||
+30
-15
@@ -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',
|
||||
);
|
||||
|
||||
+33
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user