Converge Pro license actions on shared Button primitives

This commit is contained in:
rcourtman 2026-06-13 20:37:12 +01:00
parent 51c47a2279
commit 66002e8ba7
8 changed files with 121 additions and 13 deletions

View file

@ -594,6 +594,10 @@ or other self-hosted uncapped continuity plans.
URLs must compose `ButtonLink`. `ProLicensePlanSection.tsx` must not
recreate local anchor button shells for purchase returns, private-runtime
actions, or plan-comparison CTAs.
Self-hosted plan refresh, entitlement retry, activation, and clear-license
actions are cloud-paid behaviors, but their button chrome must compose the
frontend-primitives `Button` family instead of carrying Pro-license-local
primary, secondary, or warning action class strings.
18. Add or change paid relay settings and pairing presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`, `frontend-modern/src/components/Settings/RelayPairingSection.tsx`, and `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`. The retired Dashboard shell must not be restored to carry a Relay onboarding card or equivalent blanket upsell — relay discovery stays inside its owning settings surface.
Public demo and other read-only presentation policy states must suppress
relay setup and upsell onboarding instead of inviting pairing or commercial

View file

@ -559,6 +559,11 @@ not a replacement status card, CTA band, or page-local nested card.
restoring footer-local action shells.
Reporting surfaces must use the same primitive for retry, generate, and
export actions rather than restoring large local CTA button shells.
Self-hosted commercial plan, retry, activation, and clear-key actions follow
the same shared Button boundary: commercial surfaces own the labels,
entitlement state, and click handlers, while `Button`, `ButtonLink`, and
`UpgradeButtonLink` own the primary, outline, warning, and upgrade/link
chrome.
If a new surface needs a variant that the shared primitive does not expose,
extend the primitive and registry guard rather than adding a page-local
class string.

View file

@ -936,8 +936,11 @@
{ "path": "src/components/Settings/InfrastructureInstallerSection.tsx" },
{ "path": "src/components/Settings/InfrastructureSourceManager.tsx" },
{ "path": "src/components/Settings/InfrastructureWorkspace.tsx" },
{ "path": "src/components/Settings/ProLicensePanel.tsx" },
{ "path": "src/components/Settings/ProLicensePlanSection.tsx" },
{ "path": "src/components/Settings/ReportingPanel.tsx" },
{ "path": "src/components/Settings/ResourcePicker.tsx" },
{ "path": "src/components/Settings/SelfHostedCommercialRecoverySection.tsx" },
{ "path": "src/components/UpdateConfirmationModal.tsx" },
{ "path": "src/components/UpdateProgressModal.tsx" },
{ "path": "src/components/Workloads/GuestDrawer.tsx" },
@ -979,6 +982,25 @@
"bg-emerald-600 text-white hover:bg-emerald-700"
]
},
{
"path": "src/components/Settings/ProLicensePanel.tsx",
"patterns": [
"inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60"
]
},
{
"path": "src/components/Settings/ProLicensePlanSection.tsx",
"patterns": [
"mt-2 inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-xs font-medium rounded-md border border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-800 transition-colors disabled:opacity-60"
]
},
{
"path": "src/components/Settings/SelfHostedCommercialRecoverySection.tsx",
"patterns": [
"min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed",
"min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
]
},
{
"path": "src/components/Settings/InfrastructureInstallerSection.tsx",
"patterns": [

View file

@ -5,6 +5,7 @@ import { ProLicensePlanSection } from './ProLicensePlanSection';
import { SelfHostedCommercialRecoverySection } from './SelfHostedCommercialRecoverySection';
import { useProLicensePanelState } from './useProLicensePanelState';
import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation';
import { Button } from '@/components/shared/Button';
import { Subtabs } from '@/components/shared/Subtabs';
import {
presentationPolicyHidesCommercialSurfaces,
@ -53,14 +54,16 @@ const ProLicensePanelContent: Component = () => {
<CommercialBillingShell
title={SELF_HOSTED_PRO_BILLING_PRESENTATION.shellTitle}
action={
<button
class="inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60"
<Button
variant="outline"
size="settingsAction"
class="gap-2"
disabled={state.loading()}
onClick={state.loadPanelData}
>
<RefreshCw class={`w-3.5 h-3.5 ${state.loading() ? 'animate-spin' : ''}`} />
{SELF_HOSTED_PRO_BILLING_PRESENTATION.refreshLabel}
</button>
</Button>
}
loading={false}
>

View file

@ -1,6 +1,6 @@
import { Component, For, Show } from 'solid-js';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import { ButtonLink } from '@/components/shared/Button';
import { Button, ButtonLink } from '@/components/shared/Button';
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import { licenseEntitlementsLoadError } from '@/stores/licenseEntitlements';
import {
@ -356,15 +356,17 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
<p class="text-xs text-amber-700 dark:text-amber-300 mt-1">
The license server could not be reached. Some features may be temporarily restricted.
</p>
<button
<Button
type="button"
class="mt-2 inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-xs font-medium rounded-md border border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-800 transition-colors disabled:opacity-60"
variant="warning"
size="settingsActionXs"
class="mt-2 gap-2"
disabled={props.loading}
onClick={props.onReload}
>
<RefreshCw class={`w-3 h-3 ${props.loading ? 'animate-spin' : ''}`} />
Retry
</button>
</Button>
</div>
</Show>
<Show when={!licenseEntitlementsLoadError()}>

View file

@ -2,6 +2,7 @@ import { Component, Show } from 'solid-js';
import { formField, controlClass } from '@/components/shared/Form';
import { ExternalTextLink } from '@/components/shared/ExternalTextLink';
import { FormTextarea } from '@/components/shared/FormTextarea';
import { Button } from '@/components/shared/Button';
import { SELF_HOSTED_RECOVERY_PRESENTATION } from '@/utils/licensePresentation';
import { TERMS_DOC_URL } from '@/utils/docsLinks';
import { CommercialSection } from './CommercialBillingSections';
@ -91,24 +92,26 @@ export const SelfHostedCommercialRecoverySection: Component<
</div>
<div class="flex flex-wrap items-center gap-2">
<button
class="min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
<Button
variant="primary"
size="settingsAction"
onClick={props.onActivate}
disabled={props.activating || !props.licenseKey.trim()}
>
{props.activating
? SELF_HOSTED_RECOVERY_PRESENTATION.activatePendingLabel
: SELF_HOSTED_RECOVERY_PRESENTATION.activateIdleLabel}
</button>
<button
class="min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
</Button>
<Button
variant="outline"
size="settingsAction"
onClick={props.onClear}
disabled={props.clearing || props.loading || !props.hasLicenseDetails}
>
{props.clearing
? SELF_HOSTED_RECOVERY_PRESENTATION.clearPendingLabel
: SELF_HOSTED_RECOVERY_PRESENTATION.clearIdleLabel}
</button>
</Button>
</div>
</div>
</details>

View file

@ -1138,6 +1138,16 @@ describe('ProLicensePanel', () => {
expect(proLicensePanelSource).toContain('SelfHostedCommercialRecoverySection');
expect(selfHostedCommercialRecoverySectionSource).toContain('FormTextarea');
expect(selfHostedCommercialRecoverySectionSource).not.toContain('<textarea');
expect(selfHostedCommercialRecoverySectionSource).toContain('@/components/shared/Button');
expect(selfHostedCommercialRecoverySectionSource).toContain('variant="primary"');
expect(selfHostedCommercialRecoverySectionSource).toContain('variant="outline"');
expect(selfHostedCommercialRecoverySectionSource).toContain('size="settingsAction"');
expect(selfHostedCommercialRecoverySectionSource).not.toContain(
'min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
);
expect(selfHostedCommercialRecoverySectionSource).not.toContain(
'min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
);
expect(proLicensePanelSource).toContain('SELF_HOSTED_PRO_BILLING_PRESENTATION');
expect(proLicensePanelSource).toContain('value={state.activeSection()}');
expect(proLicensePanelSource).toContain('<Subtabs');
@ -1159,6 +1169,12 @@ describe('ProLicensePanel', () => {
expect(proLicensePanelStateSource).not.toContain('runStartProTrialAction({');
expect(proLicensePanelStateSource).not.toContain('startProTrial()');
expect(proLicensePanelStateSource).toContain("'A license or activation key is required'");
expect(proLicensePanelSource).toContain('@/components/shared/Button');
expect(proLicensePanelSource).toContain('variant="outline"');
expect(proLicensePanelSource).toContain('size="settingsAction"');
expect(proLicensePanelSource).not.toContain(
'inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60',
);
expect(proLicensePlanSectionSource).toContain('getLicenseStatusLoadingState');
expect(proLicensePlanSectionSource).toContain('getNoActiveSelfHostedActivationState');
expect(proLicensePlanSectionSource).not.toContain('getTrialEndedProLicenseNotice');
@ -1167,7 +1183,13 @@ describe('ProLicensePanel', () => {
expect(proLicensePlanSectionSource).toContain('currentPlanSummary.privateRuntimeAction');
expect(proLicensePlanSectionSource).toContain('props.activationSuccessSummary');
expect(proLicensePlanSectionSource).toContain('summary().actionUrl');
expect(proLicensePlanSectionSource).toContain('Button');
expect(proLicensePlanSectionSource).toContain('ButtonLink');
expect(proLicensePlanSectionSource).toContain('variant="warning"');
expect(proLicensePlanSectionSource).toContain('size="settingsActionXs"');
expect(proLicensePlanSectionSource).not.toContain(
'mt-2 inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-xs font-medium rounded-md border border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-800 transition-colors disabled:opacity-60',
);
expect(proLicensePlanSectionSource).toContain('UpgradeButtonLink');
expect(selfHostedCommercialRecoverySectionSource).toContain('ExternalTextLink');
expect(selfHostedCommercialRecoverySectionSource).not.toContain('target="_blank"');

View file

@ -254,6 +254,7 @@ import organizationAccessMembersSectionSource from '@/components/Settings/Organi
import organizationIncomingSharesSectionSource from '@/components/Settings/OrganizationIncomingSharesSection.tsx?raw';
import organizationOutgoingSharesSectionSource from '@/components/Settings/OrganizationOutgoingSharesSection.tsx?raw';
import organizationOverviewMembersSectionSource from '@/components/Settings/OrganizationOverviewMembersSection.tsx?raw';
import proLicensePanelSource from '@/components/Settings/ProLicensePanel.tsx?raw';
import proLicensePlanSectionSource from '@/components/Settings/ProLicensePlanSection.tsx?raw';
import reportingPanelSource from '@/components/Settings/ReportingPanel.tsx?raw';
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
@ -2949,8 +2950,11 @@ describe('shared primitive guardrails', () => {
'src/components/Settings/InfrastructureInstallerSection.tsx',
'src/components/Settings/InfrastructureSourceManager.tsx',
'src/components/Settings/InfrastructureWorkspace.tsx',
'src/components/Settings/ProLicensePanel.tsx',
'src/components/Settings/ProLicensePlanSection.tsx',
'src/components/Settings/ReportingPanel.tsx',
'src/components/Settings/ResourcePicker.tsx',
'src/components/Settings/SelfHostedCommercialRecoverySection.tsx',
'src/components/UpdateConfirmationModal.tsx',
'src/components/UpdateProgressModal.tsx',
'src/components/Workloads/GuestDrawer.tsx',
@ -2993,6 +2997,25 @@ describe('shared primitive guardrails', () => {
'bg-emerald-600 text-white hover:bg-emerald-700',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/ProLicensePanel.tsx',
patterns: expect.arrayContaining([
'inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/ProLicensePlanSection.tsx',
patterns: expect.arrayContaining([
'mt-2 inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-xs font-medium rounded-md border border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-800 transition-colors disabled:opacity-60',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/SelfHostedCommercialRecoverySection.tsx',
patterns: expect.arrayContaining([
'min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
'min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/InfrastructureInstallerSection.tsx',
patterns: expect.arrayContaining([
@ -3581,6 +3604,30 @@ describe('shared primitive guardrails', () => {
);
expect(reportingPanelSource).not.toContain('bg-blue-600 text-white hover:bg-blue-700');
expect(reportingPanelSource).not.toContain('bg-emerald-600 text-white hover:bg-emerald-700');
expect(proLicensePanelSource).toContain('@/components/shared/Button');
expect(proLicensePanelSource).toContain('<Button');
expect(proLicensePanelSource).toContain('variant="outline"');
expect(proLicensePanelSource).toContain('size="settingsAction"');
expect(proLicensePanelSource).not.toContain(
'inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60',
);
expect(proLicensePlanSectionSource).toContain('@/components/shared/Button');
expect(proLicensePlanSectionSource).toContain('<Button');
expect(proLicensePlanSectionSource).toContain('variant="warning"');
expect(proLicensePlanSectionSource).toContain('size="settingsActionXs"');
expect(proLicensePlanSectionSource).not.toContain(
'mt-2 inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-2 text-xs font-medium rounded-md border border-amber-300 dark:border-amber-700 text-amber-800 dark:text-amber-200 hover:bg-amber-100 dark:hover:bg-amber-800 transition-colors disabled:opacity-60',
);
expect(selfHostedCommercialRecoverySectionSource).toContain('@/components/shared/Button');
expect(selfHostedCommercialRecoverySectionSource).toContain('variant="primary"');
expect(selfHostedCommercialRecoverySectionSource).toContain('variant="outline"');
expect(selfHostedCommercialRecoverySectionSource).toContain('size="settingsAction"');
expect(selfHostedCommercialRecoverySectionSource).not.toContain(
'min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
);
expect(selfHostedCommercialRecoverySectionSource).not.toContain(
'min-h-10 sm:min-h-9 px-4 py-2.5 text-sm font-medium rounded-md border border-border text-base-content hover:bg-surface-hover transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
);
expect(discoveryTabSource).toContain('@/components/shared/Button');
expect(discoveryTabSource).toContain('@/components/shared/CopyableCodeRow');
expect(discoveryTabSource).toContain('CopyValueButton');