diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 9a4d14f02..13cdf42fd 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -251,6 +251,10 @@ Community limit enforcement. Public demo and other read-only presentation policy states must suppress relay setup and upsell onboarding instead of inviting pairing or commercial action from a governed non-manageable surface. + Relay settings must also translate internal registration-token failures + into an operator-actionable activation-required state. Customer-facing + Relay settings must not render raw `register:` or license-token-provider + diagnostics from the relay client as the primary status message. 19. Add or change cloud plan presentation through `frontend-modern/src/pages/CloudPricing.tsx` That same presentation boundary also owns truthful customer-entry copy for hosted Cloud pricing and signup. Cloud CTA labels, setup steps, and diff --git a/frontend-modern/src/components/Settings/RelaySettingsPanel.tsx b/frontend-modern/src/components/Settings/RelaySettingsPanel.tsx index be873db11..1215b04a9 100644 --- a/frontend-modern/src/components/Settings/RelaySettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/RelaySettingsPanel.tsx @@ -102,8 +102,8 @@ export const RelaySettingsPanel: Component = (props) => - -
{state.status()!.last_error}
+ + {(message) =>
{message()}
}
diff --git a/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx b/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx index 801e6fdf7..d3e83ddbf 100644 --- a/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx @@ -241,6 +241,29 @@ describe('RelaySettingsPanel runtime', () => { expect(getQRPayloadMock).not.toHaveBeenCalled(); }); + it('shows activation-required copy instead of raw missing-token relay errors', async () => { + getRelayStatusMock.mockResolvedValueOnce({ + connected: false, + instance_id: 'instance-local', + active_channels: 0, + last_error: 'register: no license token available', + }); + + render(() => ); + + await waitFor(() => { + expect(screen.getByText('Activation required')).toBeInTheDocument(); + }); + + expect( + screen.getByText( + 'Remote Access is enabled, but this instance does not have an active Relay token. Activate Relay or turn Remote Access off before pairing mobile clients.', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('register: no license token available')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Pair New Device' })).not.toBeInTheDocument(); + }); + it('deletes the minted pairing token when onboarding payload generation fails', async () => { getQRPayloadMock.mockRejectedValueOnce(new Error('missing auth token')); diff --git a/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts b/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts index 51462aa41..316bee683 100644 --- a/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts +++ b/frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts @@ -9,7 +9,10 @@ import { RelayAPI, type RelayConfig, type RelayStatus } from '@/api/relay'; import { OnboardingAPI, type OnboardingQRResponse } from '@/api/onboarding'; import { SecurityAPI, type APITokenRecord } from '@/api/security'; import { logger } from '@/utils/logger'; -import { getRelayConnectionPresentation } from '@/utils/relayPresentation'; +import { + getRelayConnectionPresentation, + getRelayStatusErrorMessage, +} from '@/utils/relayPresentation'; import QRCode from 'qrcode'; export interface RelaySettingsPanelProps { @@ -35,6 +38,7 @@ export function useRelaySettingsPanelState(props: RelaySettingsPanelProps) { const connectionPresentation = createMemo(() => getRelayConnectionPresentation(config(), status()), ); + const statusErrorMessage = createMemo(() => getRelayStatusErrorMessage(status())); const canShowPairing = createMemo(() => Boolean(config()?.enabled && status()?.connected)); createEffect((wasPaywallVisible: boolean) => { @@ -267,6 +271,7 @@ export function useRelaySettingsPanelState(props: RelaySettingsPanelProps) { showUpgradePrompts, showPairing, status, + statusErrorMessage, upgradeDestination, }; } diff --git a/frontend-modern/src/utils/__tests__/relayPresentation.test.ts b/frontend-modern/src/utils/__tests__/relayPresentation.test.ts index 76daf4781..a0355c1f8 100644 --- a/frontend-modern/src/utils/__tests__/relayPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/relayPresentation.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + getRelayStatusErrorMessage, getRelayConnectionPresentation, getRelayDiagnosticClass, + RELAY_ACTIVATION_REQUIRED_LABEL, + RELAY_ACTIVATION_REQUIRED_MESSAGE, RELAY_CODE_BLOCK_CLASS, RELAY_ENABLE_HELP_TEXT, RELAY_INFO_MESSAGE_CLASS, @@ -49,6 +52,42 @@ describe('relayPresentation', () => { }); }); + it('returns activation-required presentation for missing Relay token errors', () => { + expect( + getRelayConnectionPresentation( + { enabled: true } as never, + { + connected: false, + active_channels: 0, + last_error: 'register: no license token available', + }, + ), + ).toEqual({ + variant: 'danger', + label: RELAY_ACTIVATION_REQUIRED_LABEL, + pulse: false, + }); + }); + + it('translates missing Relay token errors while preserving other diagnostics', () => { + expect( + getRelayStatusErrorMessage({ + connected: false, + active_channels: 0, + last_error: 'register: no license token available', + }), + ).toBe(RELAY_ACTIVATION_REQUIRED_MESSAGE); + + expect( + getRelayStatusErrorMessage({ + connected: false, + active_channels: 0, + last_error: 'relay handshake failed', + }), + ).toBe('relay handshake failed'); + expect(getRelayStatusErrorMessage(null)).toBeNull(); + }); + it('centralizes relay action and diagnostics styling', () => { expect(RELAY_READONLY_NOTICE_CLASS).toContain('border-blue-200'); expect(RELAY_PRIMARY_BUTTON_CLASS).toContain('bg-blue-600'); @@ -71,6 +110,8 @@ describe('relayPresentation', () => { expect(RELAY_PAIRING_AVAILABILITY_TITLE).toBe('Pair Pulse Mobile through Relay'); expect(RELAY_PAIRING_AVAILABILITY_MESSAGE).toContain('QR code or deep link'); expect(RELAY_ENABLE_HELP_TEXT).toContain('Pulse Mobile pairing'); + expect(RELAY_ACTIVATION_REQUIRED_LABEL).toBe('Activation required'); + expect(RELAY_ACTIVATION_REQUIRED_MESSAGE).toContain('active Relay token'); }); it('does not retain retired Relay price or trial-era onboarding copy', () => { diff --git a/frontend-modern/src/utils/relayPresentation.ts b/frontend-modern/src/utils/relayPresentation.ts index 41505f90b..9d1c58d96 100644 --- a/frontend-modern/src/utils/relayPresentation.ts +++ b/frontend-modern/src/utils/relayPresentation.ts @@ -7,6 +7,8 @@ export interface RelayConnectionPresentation { pulse: boolean; } +const RELAY_MISSING_TOKEN_ERROR = /\b(?:no license token available|license token provider not configured)\b/i; + export const RELAY_READONLY_NOTICE_CLASS = 'border border-blue-200 text-xs text-blue-800 dark:border-blue-800 dark:text-blue-200'; export const RELAY_PRIMARY_BUTTON_CLASS = @@ -35,6 +37,9 @@ export const RELAY_PAIRING_AVAILABILITY_MESSAGE = 'Supported Pulse Mobile clients connect to this Pulse instance with a QR code or deep link over end-to-end encrypted relay connectivity.'; export const RELAY_ENABLE_HELP_TEXT = 'Connect this Pulse instance to the relay server for secure remote access and Pulse Mobile pairing.'; +export const RELAY_ACTIVATION_REQUIRED_LABEL = 'Activation required'; +export const RELAY_ACTIVATION_REQUIRED_MESSAGE = + 'Remote Access is enabled, but this instance does not have an active Relay token. Activate Relay or turn Remote Access off before pairing mobile clients.'; export function getRelayDiagnosticClass(severity: 'warning' | 'error'): string { return severity === 'error' @@ -62,9 +67,32 @@ export function getRelayConnectionPresentation( }; } + if (isRelayMissingTokenError(status?.last_error)) { + return { + variant: 'danger', + label: RELAY_ACTIVATION_REQUIRED_LABEL, + pulse: false, + }; + } + return { variant: 'danger', label: 'Disconnected', pulse: false, }; } + +export function getRelayStatusErrorMessage(status?: RelayStatus | null): string | null { + const error = status?.last_error?.trim(); + if (!error) { + return null; + } + if (isRelayMissingTokenError(error)) { + return RELAY_ACTIVATION_REQUIRED_MESSAGE; + } + return error; +} + +function isRelayMissingTokenError(error?: string | null): boolean { + return RELAY_MISSING_TOKEN_ERROR.test(error ?? ''); +}