feat(branding): customize application identity

This commit is contained in:
courtmanr@gmail.com
2026-07-30 16:25:36 +01:00
parent 647d062531
commit 672db9113f
27 changed files with 855 additions and 35 deletions
+7 -1
View File
@@ -236,7 +236,13 @@ environment (`PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME`,
model each client runtime has its own settings, so the override is
per-client; in shared-process mode the settings override applies
instance-wide, so all organizations share one brand (usually yours). Branding
requires the `white_label` entitlement on the licence.
requires the `white_label` entitlement on the licence. Entitled administrators
can edit the settings-based display name and bounded inline PNG, JPEG, or GIF
under **Settings → System → General → Appearance**. That override is used by
both generated reports and the authenticated application header; the browser
title follows the configured display name. Without the entitlement, the
runtime returns and renders the built-in Pulse identity even if branding
settings remain stored.
Scheduled reports are tenant-local. In provider-hosted MSP, each client
runtime stores its own schedules in `report_schedules.json`, writes generated
@@ -8711,3 +8711,22 @@ to configure a public URL. `TestContract_SSOProviderResponseBaseURLNeverGuessesL
in `internal/api/contract_test.go` pins the precedence, the request fallback,
the trusted-proxy gate on forwarded headers, and the empty-rather-than-wrong
behavior.
### Runtime branding is a narrow presentation contract
`GET /api/runtime/branding` is the authenticated `monitoring:read` boundary
for application-shell branding. It returns only `enabled`, `displayName`, and
the validated inline `logoDataUrl`; it never returns the system settings
document, filesystem logo paths, provider credentials, or licence material.
The handler returns the canonical disabled/empty shape unless the request's
active licence service grants `white_label`, even when `reportBranding`
remains persisted.
The settings write contract remains `reportBranding` on
`POST /api/system/settings/update`, protected by `settings:write` and the
existing bounded display-name, base64, and PNG/JPEG/GIF validation. The
runtime read normalizes valid image bytes to a canonical data URL and omits an
invalid or unknown image without discarding a valid display name.
`internal/api/runtime_branding_test.go` and
`internal/api/route_inventory_test.go` pin the entitlement, shape, image, and
route contracts.
@@ -5502,3 +5502,22 @@ as the card. `settingsArchitecture.test.ts` pins the guidance owner, the absent
localhost literal, and the corrected post-save copy; the rendered fallbacks and
copy affordances are covered by
`frontend-modern/src/components/Settings/__tests__/SSOProvidersPanel.test.tsx`.
### Entitled application branding stays app-shell-owned
`frontend-modern/src/stores/systemSettings.ts` owns the narrow reactive
runtime-brand payload loaded during authenticated bootstrap. The shared
`frontend-modern/src/AppLayout.tsx` shell is the only owner of applying that
payload to the centered header lockup and route-aware browser title. A custom
bounded banner logo replaces the built-in mark; a non-empty display name
replaces the Pulse wordmark, while a logo with an empty display name may stand
alone. Kiosk mode keeps its existing hidden-header behavior.
The Appearance surface edits the already-canonical `reportBranding` object
through `BrandingSettingsCard`; it accepts PNG, JPEG, or GIF files no larger
than the persisted inline-logo boundary, previews the exact saved material,
marks the shared settings form dirty, and provides an explicit remove action.
It must not create a page-local branding cache or render configured values
when `white_label` is unavailable. Focused proofs live in
`BrandingSettingsCard.test.tsx`, `AppLayout.test.tsx`, and
`stores/__tests__/systemSettings.test.ts`.
@@ -1789,3 +1789,22 @@ already enforces scopes server-side and no backend behaviour changed.
Regression coverage: the `Issue1650` cases in
`frontend-modern/src/utils/__tests__/securityScorePresentation.test.ts` and
`frontend-modern/src/components/__tests__/SecurityWarning.test.tsx`.
### Runtime branding reveals presentation material only after entitlement
The authenticated application header cannot depend on the admin-only full
system-settings response, so `/api/runtime/branding` is deliberately readable
with `monitoring:read`. That wider read authority is safe only because the
payload is a strict allowlist of three presentation fields and the server
fails closed: without the active `white_label` entitlement it returns
`enabled: false` with empty name and logo values. It never exposes
`logoPath`, other settings, environment values, licence records, or storage
locations.
Brand mutations remain behind `settings:write` and the existing report-brand
validation rejects unsupported keys, newlines, oversized base64, malformed
base64, and formats outside PNG/JPEG/GIF. Browser rendering consumes only the
server-filtered runtime payload; hiding controls or checking a client-side
capability is not treated as the authorization boundary.
`internal/api/runtime_branding_test.go` proves the no-entitlement non-leakage
and image normalization.
@@ -4818,3 +4818,12 @@ only for a session that can reach settings, using the single
handoff, demo organization suppression, and platform-page route carriage in
`frontend-modern/src/App.tsx` are unchanged; nothing about backup, restore, or
recovery routing keys off banner visibility.
The persisted `reportBranding` object remains adjacent tenant/runtime
configuration rather than storage inventory or recovery state. The
application-header extension reuses that same object and does not create a
second logo artifact, database table, or backup surface. Existing system
configuration export/restore therefore carries the display name and bounded
inline logo exactly as it already carries report branding. Runtime reads
through `/api/runtime/branding` are read-only and entitlement-filtered; they do
not mutate, relocate, or synthesize persisted branding state.
+57 -31
View File
@@ -54,6 +54,7 @@ import { getActionApprovalBadgePresentation } from '@/features/actions/actionPre
import { actionInboxStore } from '@/stores/actionInbox';
import { patrolAttentionStore } from '@/stores/patrolAttention';
import { isPro } from '@/stores/licenseCommercial';
import { runtimeBranding } from '@/stores/systemSettings';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import { getAssistantPageContext } from '@/utils/assistantPageContext';
import type { AppConnectionStatus } from '@/useAppRuntimeState';
@@ -235,6 +236,13 @@ export function AppLayout(props: AppLayoutProps) {
const location = useLocation();
const kioskMode = useKioskMode();
const brandMotionActive = createMemo(() => props.connectionStatus().tone === 'healthy');
const customBrandLogo = createMemo(() =>
runtimeBranding().enabled ? runtimeBranding().logoDataUrl : '',
);
const customBrandName = createMemo(() =>
runtimeBranding().enabled ? runtimeBranding().displayName.trim() : '',
);
const browserBrandName = createMemo(() => customBrandName() || 'Pulse');
const [headerVisible, setHeaderVisible] = createSignal(true);
const [skipLinkFocused, setSkipLinkFocused] = createSignal(false);
@@ -290,7 +298,7 @@ export function AppLayout(props: AppLayoutProps) {
createEffect(() => {
const active = getActiveTabForPath(location.pathname);
if (!active) {
document.title = 'Pulse';
document.title = browserBrandName();
return;
}
// The standalone (Machines) section has sub-tabs with their own labels.
@@ -298,10 +306,10 @@ export function AppLayout(props: AppLayoutProps) {
// the actual page (e.g. "Availability checks" not "Machines").
if (active === 'standalone') {
const subTab = resolveStandaloneSubTabTitle(location.pathname);
document.title = `${subTab} · Pulse`;
document.title = `${subTab} · ${browserBrandName()}`;
return;
}
document.title = `${tabTitleByActive[active]} · Pulse`;
document.title = `${tabTitleByActive[active]} · ${browserBrandName()}`;
});
const toggleKioskMode = () => {
@@ -754,37 +762,55 @@ export function AppLayout(props: AppLayoutProps) {
<Show when={!kioskMode()}>
<div class="flex items-center gap-2 sm:flex-initial sm:gap-2 sm:col-start-2 sm:col-end-3 sm:justify-self-center">
<div
class={`pulse-brand-lockup flex items-center gap-2 ${brandMotionActive() ? 'animate-pulse-brand' : ''}`}
class={`pulse-brand-lockup flex items-center gap-2 ${!customBrandLogo() && brandMotionActive() ? 'animate-pulse-brand' : ''}`}
data-testid="pulse-brand-lockup"
>
<svg
width="20"
height="20"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
class="pulse-brand-logo"
<Show
when={customBrandLogo()}
fallback={
<svg
width="20"
height="20"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
class="pulse-brand-logo"
>
<title>Pulse Logo</title>
<circle
class="pulse-bg fill-blue-600 dark:fill-blue-500"
cx="128"
cy="128"
r="122"
/>
<circle
class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]"
cx="128"
cy="128"
r="84"
/>
<circle
class="pulse-center fill-white dark:fill-[#dbeafe]"
cx="128"
cy="128"
r="26"
/>
</svg>
}
>
<title>Pulse Logo</title>
<circle
class="pulse-bg fill-blue-600 dark:fill-blue-500"
cx="128"
cy="128"
r="122"
/>
<circle
class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]"
cx="128"
cy="128"
r="84"
/>
<circle
class="pulse-center fill-white dark:fill-[#dbeafe]"
cx="128"
cy="128"
r="26"
/>
</svg>
<span class="pulse-brand-wordmark text-lg font-medium text-base-content">Pulse</span>
{(logoDataUrl) => (
<img
src={logoDataUrl()}
alt={customBrandName() ? `${customBrandName()} logo` : 'Custom logo'}
class="max-h-8 max-w-[12rem] object-contain"
data-testid="custom-brand-logo"
/>
)}
</Show>
<Show when={customBrandName() || !customBrandLogo()}>
<span class="pulse-brand-wordmark text-lg font-medium text-base-content">
{customBrandName() || 'Pulse'}
</span>
</Show>
<Show when={props.versionInfo()?.channel === 'rc'}>
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
Preview
@@ -11,6 +11,10 @@ import {
import { isKioskMode, setKioskMode } from '@/utils/url';
import type { PlatformNavigationVisibility } from '@/features/platformNavigation/platformNavigationModel';
import { aiChatStore } from '@/stores/aiChat';
import {
clearRuntimeBranding,
updateRuntimeBrandingFromResponse,
} from '@/stores/systemSettings';
HTMLElement.prototype.scrollIntoView = vi.fn();
window.scrollTo = vi.fn();
@@ -117,6 +121,7 @@ describe('AppLayout navigation icons', () => {
window.history.replaceState({}, '', '/settings/infrastructure');
resetPrimaryNavigationRouteMemory();
patrolAttentionMockState.activeCount = 0;
clearRuntimeBranding();
aiChatStore.close();
aiChatStore.setEnabled(true);
});
@@ -124,6 +129,7 @@ describe('AppLayout navigation icons', () => {
afterEach(() => {
aiChatStore.close();
aiChatStore.setEnabled(false);
clearRuntimeBranding();
cleanup();
});
@@ -334,6 +340,24 @@ describe('AppLayout navigation icons', () => {
expect(container.querySelector('.animate-pulse-logo')).toBeNull();
});
it('renders entitled custom branding and uses its name in the browser title', () => {
updateRuntimeBrandingFromResponse({
enabled: true,
displayName: 'Acme Operations',
logoDataUrl: 'data:image/png;base64,YWJj',
});
renderLayout();
expect(screen.getByTestId('custom-brand-logo')).toHaveAttribute(
'src',
'data:image/png;base64,YWJj',
);
expect(screen.getByText('Acme Operations')).toBeInTheDocument();
expect(screen.getByTestId('pulse-brand-lockup')).not.toHaveClass('animate-pulse-brand');
expect(document.title).toBe('Settings · Acme Operations');
});
it('keeps the assistant launcher clear of the mobile navigation breakpoint', () => {
renderLayout();
@@ -238,6 +238,7 @@ describe('useAppRuntimeState', () => {
}));
vi.doMock('@/stores/systemSettings', () => ({
loadRuntimeBranding: vi.fn().mockResolvedValue(undefined),
markSystemSettingsLoadedWithDefaults: vi.fn(),
updateSystemSettingsFromResponse: vi.fn(),
}));
@@ -186,6 +186,22 @@ describe('SettingsAPI', () => {
});
});
describe('getRuntimeBranding', () => {
it('fetches the narrow runtime branding payload', async () => {
const branding = {
enabled: true,
displayName: 'Acme Operations',
logoDataUrl: 'data:image/png;base64,YWJj',
};
vi.mocked(apiFetchJSON).mockResolvedValueOnce(branding);
const result = await SettingsAPI.getRuntimeBranding();
expect(apiFetchJSON).toHaveBeenCalledWith('/api/runtime/branding');
expect(result).toEqual(branding);
});
});
describe('getTelemetryPreview', () => {
it('fetches the telemetry preview payload', async () => {
const mockPreview = {
+10
View File
@@ -156,6 +156,12 @@ export interface TelemetryPreviewResponse {
payload: TelemetryPingPreview;
}
export interface RuntimeBrandingResponse {
enabled: boolean;
displayName: string;
logoDataUrl: string;
}
export class SettingsAPI {
private static baseUrl = '/api';
@@ -172,6 +178,10 @@ export class SettingsAPI {
return apiFetchJSON(`${this.baseUrl}/system/settings`) as Promise<SystemSettingsResponse>;
}
static async getRuntimeBranding(): Promise<RuntimeBrandingResponse> {
return apiFetchJSON(`${this.baseUrl}/runtime/branding`) as Promise<RuntimeBrandingResponse>;
}
static async getTelemetryPreview(): Promise<TelemetryPreviewResponse> {
return apiFetchJSON(
`${this.baseUrl}/system/settings/telemetry-preview`,
@@ -0,0 +1,198 @@
import { Show, createSignal, type Accessor, type Component, type Setter } from 'solid-js';
import ImageIcon from 'lucide-solid/icons/image';
import Trash2 from 'lucide-solid/icons/trash-2';
import { Button } from '@/components/shared/Button';
import { FeatureGateSection } from '@/components/shared/FeatureGateSection';
import { formControl, formHelpText, formLabel } from '@/components/shared/Form';
import { hasFeature } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import type { ReportBrandSettings } from '@/types/config';
export const BRAND_LOGO_MAX_BYTES = 36 * 1024;
type BrandLogoFormat = NonNullable<ReportBrandSettings['logoFormat']>;
export interface BrandingSettingsCardProps {
displayName: Accessor<string>;
setDisplayName: Setter<string>;
logoBase64: Accessor<string>;
setLogoBase64: Setter<string>;
logoFormat: Accessor<BrandLogoFormat>;
setLogoFormat: Setter<BrandLogoFormat>;
setHasUnsavedChanges: Setter<boolean>;
}
export function brandLogoFormatForFile(file: Pick<File, 'type' | 'name'>): BrandLogoFormat | null {
const mime = file.type.toLowerCase();
if (mime === 'image/png') return 'png';
if (mime === 'image/jpeg') return 'jpg';
if (mime === 'image/gif') return 'gif';
const extension = file.name.toLowerCase().split('.').pop();
if (extension === 'png') return 'png';
if (extension === 'jpg' || extension === 'jpeg') return 'jpg';
if (extension === 'gif') return 'gif';
return null;
}
export function brandingLogoPreview(
logoBase64: string,
logoFormat: BrandLogoFormat,
): string {
const value = logoBase64.trim();
if (!value) return '';
if (value.startsWith('data:image/')) return value;
if (!logoFormat) return '';
const mime = logoFormat === 'jpg' || logoFormat === 'jpeg' ? 'image/jpeg' : `image/${logoFormat}`;
return `data:${mime};base64,${value}`;
}
function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error ?? new Error('Failed to read logo file'));
reader.onload = () =>
typeof reader.result === 'string'
? resolve(reader.result)
: reject(new Error('Failed to read logo file'));
reader.readAsDataURL(file);
});
}
export const BrandingSettingsCard: Component<BrandingSettingsCardProps> = (props) => {
const [fileError, setFileError] = createSignal('');
const preview = () => brandingLogoPreview(props.logoBase64(), props.logoFormat());
const markChanged = () => props.setHasUnsavedChanges(true);
const handleLogoFile = async (file: File | undefined) => {
setFileError('');
if (!file) return;
const format = brandLogoFormatForFile(file);
if (!format) {
setFileError('Choose a PNG, JPEG, or GIF image.');
return;
}
if (file.size > BRAND_LOGO_MAX_BYTES) {
setFileError('Logo files must be 36 KB or smaller.');
return;
}
try {
props.setLogoBase64(await readFileAsDataURL(file));
props.setLogoFormat(format);
markChanged();
} catch {
setFileError('Pulse could not read that logo file.');
}
};
const clearLogo = () => {
props.setLogoBase64('');
props.setLogoFormat('');
setFileError('');
markChanged();
};
return (
<Show
when={hasFeature('white_label')}
fallback={
<FeatureGateSection
title="Application branding"
body="Use a custom logo and name across the Pulse header and generated reports."
upgradeDestination={getUpgradeActionDestination('white_label')}
showUpgradePrompts={!presentationPolicyHidesUpgradePrompts()}
icon={<ImageIcon class="h-5 w-5" />}
/>
}
>
<div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,0.8fr)]">
<div class="space-y-4">
<div>
<label for="application-brand-name" class={formLabel}>
Application name
</label>
<input
id="application-brand-name"
class={formControl}
maxlength={120}
value={props.displayName()}
placeholder="Pulse"
onInput={(event) => {
props.setDisplayName(event.currentTarget.value);
markChanged();
}}
/>
<p class={formHelpText}>
Replaces Pulse in the page header and browser title. Leave blank to keep the default
name, or to show an uploaded banner by itself.
</p>
</div>
<div>
<label for="application-brand-logo" class={formLabel}>
Header logo
</label>
<input
id="application-brand-logo"
type="file"
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
class="block w-full text-sm text-muted file:mr-3 file:rounded-md file:border file:border-border file:bg-surface file:px-3 file:py-2 file:text-sm file:font-medium file:text-base-content hover:file:bg-surface-hover"
onChange={(event) => void handleLogoFile(event.currentTarget.files?.[0])}
/>
<p class={formHelpText}>
PNG, JPEG, or GIF, up to 36 KB. Transparent or dark-background banner logos work best.
</p>
<Show when={fileError()}>
<p class="mt-2 text-xs text-error" role="alert">
{fileError()}
</p>
</Show>
</div>
</div>
<div class="flex min-h-28 flex-col justify-between gap-4 rounded-md border border-border bg-base p-4">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-muted">Header preview</p>
<div class="mt-3 flex min-h-10 items-center justify-center gap-2 overflow-hidden rounded bg-surface px-3 py-2">
<Show
when={preview()}
fallback={
<span class="flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-[10px] text-white">
</span>
}
>
{(logo) => (
<img
src={logo()}
alt=""
class="max-h-8 max-w-[12rem] object-contain"
data-testid="branding-logo-preview"
/>
)}
</Show>
<Show when={props.displayName().trim() || !preview()}>
<span class="truncate text-lg font-medium text-base-content">
{props.displayName().trim() || 'Pulse'}
</span>
</Show>
</div>
</div>
<Show when={props.logoBase64()}>
<div class="flex justify-end">
<Button variant="secondary" size="sm" class="gap-2" onClick={clearLogo}>
<Trash2 class="h-4 w-4" />
Remove logo
</Button>
</div>
</Show>
</div>
</div>
</Show>
);
};
@@ -7,6 +7,8 @@ import { EnvironmentLockBadge } from '@/components/shared/EnvironmentLockBadge';
import { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
import type { TelemetryPreviewResponse } from '@/api/settings';
import { DockerRuntimeSettingsCard } from './DockerRuntimeSettingsCard';
import { BrandingSettingsCard } from './BrandingSettingsCard';
import type { ReportBrandSettings } from '@/types/config';
import Sun from 'lucide-solid/icons/sun';
import Moon from 'lucide-solid/icons/moon';
import Languages from 'lucide-solid/icons/languages';
@@ -70,6 +72,12 @@ export interface GeneralSettingsPanelProps {
setPVEPollingCustomSeconds: Setter<number>;
pvePollingEnvLocked: () => boolean;
setHasUnsavedChanges: Setter<boolean>;
reportBrandDisplayName: Accessor<string>;
setReportBrandDisplayName: Setter<string>;
reportBrandLogoBase64: Accessor<string>;
setReportBrandLogoBase64: Setter<string>;
reportBrandLogoFormat: Accessor<NonNullable<ReportBrandSettings['logoFormat']>>;
setReportBrandLogoFormat: Setter<NonNullable<ReportBrandSettings['logoFormat']>>;
telemetryEnabled: Accessor<boolean>;
telemetryEnabledLocked: () => boolean;
@@ -212,6 +220,18 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
onChange={() => layoutStore.toggle()}
/>
</div>
<div class="p-4 sm:p-6">
<BrandingSettingsCard
displayName={props.reportBrandDisplayName}
setDisplayName={props.setReportBrandDisplayName}
logoBase64={props.reportBrandLogoBase64}
setLogoBase64={props.setReportBrandLogoBase64}
logoFormat={props.reportBrandLogoFormat}
setLogoFormat={props.setReportBrandLogoFormat}
setHasUnsavedChanges={props.setHasUnsavedChanges}
/>
</div>
</SettingsPanel>
{/* Usage Data + Privacy Card */}
@@ -0,0 +1,96 @@
import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { describe, expect, it, vi } from 'vitest';
import {
BRAND_LOGO_MAX_BYTES,
BrandingSettingsCard,
brandLogoFormatForFile,
brandingLogoPreview,
} from '../BrandingSettingsCard';
vi.mock('@/stores/license', () => ({
hasFeature: () => true,
}));
vi.mock('@/stores/licenseCommercial', () => ({
getUpgradeActionDestination: () => ({ href: '/settings/billing', external: false }),
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesUpgradePrompts: () => false,
}));
function renderCard() {
const [displayName, setDisplayName] = createSignal('');
const [logoBase64, setLogoBase64] = createSignal('');
const [logoFormat, setLogoFormat] = createSignal<'' | 'png' | 'jpg' | 'jpeg' | 'gif'>('');
const [changed, setChanged] = createSignal(false);
render(() => (
<BrandingSettingsCard
displayName={displayName}
setDisplayName={setDisplayName}
logoBase64={logoBase64}
setLogoBase64={setLogoBase64}
logoFormat={logoFormat}
setLogoFormat={setLogoFormat}
setHasUnsavedChanges={setChanged}
/>
));
return { displayName, logoBase64, logoFormat, changed };
}
describe('BrandingSettingsCard', () => {
it('normalizes supported image formats and previews plain base64', () => {
expect(brandLogoFormatForFile({ type: 'image/jpeg', name: 'brand.bin' })).toBe('jpg');
expect(brandLogoFormatForFile({ type: '', name: 'brand.GIF' })).toBe('gif');
expect(brandLogoFormatForFile({ type: 'image/svg+xml', name: 'brand.svg' })).toBeNull();
expect(brandingLogoPreview('YWJj', 'png')).toBe('data:image/png;base64,YWJj');
});
it('updates the application name and marks settings dirty', async () => {
const state = renderCard();
await fireEvent.input(screen.getByLabelText('Application name'), {
target: { value: 'Acme Operations' },
});
expect(state.displayName()).toBe('Acme Operations');
expect(state.changed()).toBe(true);
expect(screen.getByText('Acme Operations')).toBeInTheDocument();
});
it('loads a bounded PNG and allows removing it', async () => {
const state = renderCard();
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'brand.png', {
type: 'image/png',
});
await fireEvent.change(screen.getByLabelText('Header logo'), {
target: { files: [file] },
});
await waitFor(() => expect(state.logoFormat()).toBe('png'));
expect(state.logoBase64()).toMatch(/^data:image\/png;base64,/);
expect(screen.getByTestId('branding-logo-preview')).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Remove logo' }));
expect(state.logoBase64()).toBe('');
expect(state.logoFormat()).toBe('');
});
it('rejects files larger than the persisted inline-logo boundary', async () => {
const state = renderCard();
const file = new File([new Uint8Array(BRAND_LOGO_MAX_BYTES + 1)], 'too-large.png', {
type: 'image/png',
});
await fireEvent.change(screen.getByLabelText('Header logo'), {
target: { files: [file] },
});
expect(await screen.findByRole('alert')).toHaveTextContent('36 KB or smaller');
expect(state.logoBase64()).toBe('');
});
});
@@ -13,6 +13,11 @@ function renderGeneralSettingsPanel(overrides: Partial<GeneralSettingsPanelProps
const [, setHasUnsavedChanges] = createSignal(false);
const [telemetryEnabled] = createSignal(true);
const [disableDockerUpdateActions] = createSignal(false);
const [reportBrandDisplayName, setReportBrandDisplayName] = createSignal('');
const [reportBrandLogoBase64, setReportBrandLogoBase64] = createSignal('');
const [reportBrandLogoFormat, setReportBrandLogoFormat] = createSignal<
'' | 'png' | 'jpg' | 'jpeg' | 'gif'
>('');
const props: GeneralSettingsPanelProps = {
darkMode: () => false,
@@ -26,6 +31,12 @@ function renderGeneralSettingsPanel(overrides: Partial<GeneralSettingsPanelProps
setPVEPollingCustomSeconds,
pvePollingEnvLocked: () => false,
setHasUnsavedChanges,
reportBrandDisplayName,
setReportBrandDisplayName,
reportBrandLogoBase64,
setReportBrandLogoBase64,
reportBrandLogoFormat,
setReportBrandLogoFormat,
telemetryEnabled,
telemetryEnabledLocked: () => false,
savingTelemetry: () => false,
@@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({
updateStoreIsDismissedMock: vi.fn(),
updateStoreClearDismissedMock: vi.fn(),
updateDockerUpdateActionsSettingMock: vi.fn(),
loadRuntimeBrandingMock: vi.fn(),
}));
vi.mock('@/api/settings', () => ({
@@ -86,6 +87,7 @@ vi.mock('@/stores/updates', () => ({
vi.mock('@/stores/systemSettings', () => ({
updateDockerUpdateActionsSetting: mocks.updateDockerUpdateActionsSettingMock,
loadRuntimeBranding: mocks.loadRuntimeBrandingMock,
}));
type HookState = ReturnType<typeof useSystemSettingsState>;
@@ -99,6 +101,7 @@ describe('useSystemSettingsState branch coverage', () => {
beforeEach(() => {
mocks.getSystemSettingsMock.mockResolvedValue({});
mocks.updateSystemSettingsMock.mockResolvedValue(undefined);
mocks.loadRuntimeBrandingMock.mockResolvedValue(undefined);
mocks.getUpdatePlanMock.mockResolvedValue({
canAutoUpdate: false,
requiresRoot: false,
@@ -242,6 +242,7 @@ describe('useSystemSettingsState', () => {
vi.doMock('@/stores/systemSettings', () => ({
updateDockerUpdateActionsSetting: vi.fn(),
loadRuntimeBranding: vi.fn().mockResolvedValue(undefined),
}));
({ useSystemSettingsState } = await import('../useSystemSettingsState'));
@@ -40,6 +40,12 @@ export function useSettingsSystemPanels(
setPVEPollingCustomSeconds: params.systemSettings.setPVEPollingCustomSeconds,
pvePollingEnvLocked: params.systemSettings.pvePollingEnvLocked,
setHasUnsavedChanges: params.systemSettings.setHasUnsavedChanges,
reportBrandDisplayName: params.systemSettings.reportBrandDisplayName,
setReportBrandDisplayName: params.systemSettings.setReportBrandDisplayName,
reportBrandLogoBase64: params.systemSettings.reportBrandLogoBase64,
setReportBrandLogoBase64: params.systemSettings.setReportBrandLogoBase64,
reportBrandLogoFormat: params.systemSettings.reportBrandLogoFormat,
setReportBrandLogoFormat: params.systemSettings.setReportBrandLogoFormat,
telemetryEnabled: params.systemSettings.telemetryEnabled,
telemetryEnabledLocked: params.systemSettings.telemetryEnabledLocked,
savingTelemetry: params.systemSettings.savingTelemetry,
@@ -6,7 +6,11 @@ import { notificationStore } from '@/stores/notifications';
import { logger } from '@/utils/logger';
import { updateStore } from '@/stores/updates';
import { copyToClipboard } from '@/utils/clipboard';
import { updateDockerUpdateActionsSetting } from '@/stores/systemSettings';
import {
loadRuntimeBranding,
updateDockerUpdateActionsSetting,
} from '@/stores/systemSettings';
import type { ReportBrandSettings } from '@/types/config';
import {
BACKUP_INTERVAL_OPTIONS,
getCheckForUpdatesErrorMessage,
@@ -58,6 +62,10 @@ export function useSystemSettingsState({
const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false);
const [savingDockerUpdateActions, setSavingDockerUpdateActions] = createSignal(false);
const [telemetryEnabled, setTelemetryEnabled] = createSignal(true);
const [reportBrandDisplayName, setReportBrandDisplayName] = createSignal('');
const [reportBrandLogoBase64, setReportBrandLogoBase64] = createSignal('');
const [reportBrandLogoFormat, setReportBrandLogoFormat] =
createSignal<NonNullable<ReportBrandSettings['logoFormat']>>('');
const [savingTelemetry, setSavingTelemetry] = createSignal(false);
const [telemetryPreview, setTelemetryPreview] = createSignal<TelemetryPreviewResponse | null>(
null,
@@ -141,6 +149,9 @@ export function useSystemSettingsState({
setHideLocalLogin(systemSettings.hideLocalLogin ?? false);
setDisableDockerUpdateActions(systemSettings.disableDockerUpdateActions ?? false);
setTelemetryEnabled(systemSettings.telemetryEnabled ?? true);
setReportBrandDisplayName(systemSettings.reportBranding?.displayName ?? '');
setReportBrandLogoBase64(systemSettings.reportBranding?.logoBase64 ?? '');
setReportBrandLogoFormat(systemSettings.reportBranding?.logoFormat ?? '');
if (typeof systemSettings.backupPollingEnabled === 'boolean') {
setBackupPollingEnabled(systemSettings.backupPollingEnabled);
@@ -221,7 +232,13 @@ export function useSystemSettingsState({
allowedEmbedOrigins: allowedEmbedOrigins(),
webhookAllowedPrivateCIDRs: webhookAllowedPrivateCIDRs(),
publicURL: publicURL(),
reportBranding: {
displayName: reportBrandDisplayName().trim(),
logoBase64: reportBrandLogoBase64(),
logoFormat: reportBrandLogoFormat(),
},
});
await loadRuntimeBranding();
}
const isNetworkTab = initiatingTab === 'system-network';
@@ -502,6 +519,12 @@ export function useSystemSettingsState({
savingDockerUpdateActions,
handleDisableDockerUpdateActionsChange,
telemetryEnabled,
reportBrandDisplayName,
setReportBrandDisplayName,
reportBrandLogoBase64,
setReportBrandLogoBase64,
reportBrandLogoFormat,
setReportBrandLogoFormat,
telemetryEnabledLocked,
savingTelemetry,
handleTelemetryEnabledChange,
@@ -6,9 +6,12 @@ import { PRIVACY_DOC_URL } from '@/utils/docsLinks';
import { EN_MESSAGES } from '@/i18n/messages';
import {
areSystemSettingsLoaded,
clearRuntimeBranding,
markSystemSettingsLoadedWithDefaults,
runtimeBranding,
shouldHideDockerUpdateActions,
shouldReduceProUpsellNoise,
updateRuntimeBrandingFromResponse,
updateSystemSettingsFromResponse,
} from '@/stores/systemSettings';
@@ -20,6 +23,7 @@ const repoRoot = path.resolve(frontendRoot, '..');
describe('systemSettings store', () => {
beforeEach(() => {
markSystemSettingsLoadedWithDefaults();
clearRuntimeBranding();
});
it('applies route and docker feature flags from API response', () => {
@@ -55,6 +59,33 @@ describe('systemSettings store', () => {
expect(shouldReduceProUpsellNoise()).toBe(false);
});
it('normalizes the entitlement-filtered runtime branding payload', () => {
updateRuntimeBrandingFromResponse({
enabled: true,
displayName: ' Acme Operations ',
logoDataUrl: 'data:image/png;base64,YWJj',
});
expect(runtimeBranding()).toEqual({
enabled: true,
displayName: 'Acme Operations',
logoDataUrl: 'data:image/png;base64,YWJj',
});
clearRuntimeBranding();
expect(runtimeBranding()).toEqual({ enabled: false, displayName: '', logoDataUrl: '' });
});
it('drops branding values from a disabled runtime response', () => {
updateRuntimeBrandingFromResponse({
enabled: false,
displayName: 'Must not render',
logoDataUrl: 'data:image/png;base64,YWJj',
});
expect(runtimeBranding()).toEqual({ enabled: false, displayName: '', logoDataUrl: '' });
});
it('keeps the telemetry disclosure on the shipped local privacy doc', () => {
expect(PRIVACY_DOC_URL).toBe('/docs/PRIVACY.md');
});
+31 -1
View File
@@ -6,7 +6,7 @@
*/
import { createSignal } from 'solid-js';
import { SettingsAPI } from '@/api/settings';
import { SettingsAPI, type RuntimeBrandingResponse } from '@/api/settings';
import { logger } from '@/utils/logger';
import type { SystemConfig } from '@/types/config';
@@ -14,6 +14,11 @@ import type { SystemConfig } from '@/types/config';
const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false);
// Server-side compatibility setting for proactive commercial prompts
const [reduceProUpsellNoise, setReduceProUpsellNoise] = createSignal(false);
const [runtimeBranding, setRuntimeBranding] = createSignal<RuntimeBrandingResponse>({
enabled: false,
displayName: '',
logoDataUrl: '',
});
// Track if settings have been loaded
const [systemSettingsLoaded, setSystemSettingsLoaded] = createSignal(false);
@@ -50,6 +55,29 @@ export async function loadSystemSettings(): Promise<void> {
}
}
export async function loadRuntimeBranding(): Promise<void> {
try {
updateRuntimeBrandingFromResponse(await SettingsAPI.getRuntimeBranding());
} catch (err) {
logger.warn('Failed to load runtime branding, using Pulse defaults', err);
clearRuntimeBranding();
}
}
export function updateRuntimeBrandingFromResponse(branding: RuntimeBrandingResponse): void {
const enabled = branding.enabled === true;
setRuntimeBranding({
enabled,
displayName:
enabled && typeof branding.displayName === 'string' ? branding.displayName.trim() : '',
logoDataUrl: enabled && typeof branding.logoDataUrl === 'string' ? branding.logoDataUrl : '',
});
}
export function clearRuntimeBranding(): void {
setRuntimeBranding({ enabled: false, displayName: '', logoDataUrl: '' });
}
/**
* Check if Docker update actions (buttons) should be hidden.
* Returns true if the server has configured to hide update buttons.
@@ -90,3 +118,5 @@ export function updateDockerUpdateActionsSetting(disabled: boolean): void {
export function updateReduceProUpsellNoiseSetting(enabled: boolean): void {
setReduceProUpsellNoise(enabled);
}
export { runtimeBranding };
+7
View File
@@ -24,6 +24,12 @@ export interface AuthConfig {
*/
export type UpdateChannel = 'stable' | 'rc';
export interface ReportBrandSettings {
displayName?: string;
logoBase64?: string;
logoFormat?: 'png' | 'jpg' | 'jpeg' | 'gif' | '';
}
export interface SystemConfig {
pvePollingInterval?: number; // PVE polling interval in seconds
pbsPollingInterval?: number; // PBS polling interval in seconds
@@ -49,6 +55,7 @@ export interface SystemConfig {
disableDockerUpdateActions?: boolean; // Hide Docker update buttons while still detecting updates (server-wide)
reduceProUpsellNoise?: boolean; // Legacy compatibility preference for proactive commercial prompts
telemetryEnabled?: boolean; // Outbound usage telemetry, enabled by default unless disabled
reportBranding?: ReportBrandSettings; // Entitlement-gated application and report branding
}
/**
+2 -1
View File
@@ -55,6 +55,7 @@ import {
} from '@/stores/sessionPresentationPolicy';
import { layoutStore } from '@/utils/layout';
import {
loadRuntimeBranding,
markSystemSettingsLoadedWithDefaults,
updateSystemSettingsFromResponse,
} from '@/stores/systemSettings';
@@ -370,7 +371,7 @@ export const useAppRuntimeState = () => {
await loadOrganizations();
setWsStore(acquireWsStore());
setBackendHealthy(true);
await loadSystemSettingsAndLayout();
await Promise.all([loadSystemSettingsAndLayout(), loadRuntimeBranding()]);
// Shared commercial posture stays off ordinary self-hosted app shells.
if (!presentationPolicyHidesUpgradePrompts()) {
void loadCommercialPosture();
+1
View File
@@ -484,6 +484,7 @@ var allRouteAllowlist = []string{
"/api/vmware/connections/",
"/api/admin/profiles/",
"/api/config/system",
"/api/runtime/branding",
"/api/system/settings/telemetry-preview",
"/api/system/settings/telemetry-reset-id",
"/api/system/mock-mode",
@@ -122,6 +122,7 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
r.mux.HandleFunc("/api/diagnostics", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleDiagnostics)))
r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken)))
r.mux.HandleFunc("/api/config", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleConfig)))
r.mux.HandleFunc("/api/runtime/branding", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.systemSettingsHandler.HandleGetRuntimeBranding)))
// Update routes
r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates)))
r.mux.HandleFunc("/api/updates/apply", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, updateHandlers.HandleApplyUpdate)))
+115
View File
@@ -0,0 +1,115 @@
package api
import (
"encoding/base64"
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
// RuntimeBrandingResponse is the narrow presentation-only branding contract
// available to authenticated monitoring clients. It intentionally omits the
// rest of system settings and returns no configured brand when the active
// runtime lacks the white_label entitlement.
type RuntimeBrandingResponse struct {
Enabled bool `json:"enabled"`
DisplayName string `json:"displayName"`
LogoDataURL string `json:"logoDataUrl"`
}
func emptyRuntimeBrandingResponse() RuntimeBrandingResponse {
return RuntimeBrandingResponse{}
}
func runtimeBrandingResponse(settings *config.ReportBrandSettings, entitled bool) RuntimeBrandingResponse {
if !entitled || settings == nil {
return emptyRuntimeBrandingResponse()
}
displayName := strings.TrimSpace(settings.DisplayName)
logoDataURL := runtimeBrandLogoDataURL(*settings)
if displayName == "" && logoDataURL == "" {
return emptyRuntimeBrandingResponse()
}
return RuntimeBrandingResponse{
Enabled: true,
DisplayName: displayName,
LogoDataURL: logoDataURL,
}
}
func runtimeBrandLogoDataURL(settings config.ReportBrandSettings) string {
decoded, err := config.DecodeReportBrandLogoBase64(settings.LogoBase64)
if err != nil || len(decoded) == 0 {
return ""
}
detectedFormat := ""
switch http.DetectContentType(decoded) {
case "image/png":
detectedFormat = "png"
case "image/jpeg":
detectedFormat = "jpg"
case "image/gif":
detectedFormat = "gif"
default:
return ""
}
format, ok := config.CanonicalReportBrandLogoFormat(settings.LogoFormat)
if !ok {
return ""
}
if format == "" {
format = detectedFormat
}
if format != detectedFormat {
return ""
}
mediaType := "image/" + format
if format == "jpg" {
mediaType = "image/jpeg"
}
return "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(decoded)
}
// HandleGetRuntimeBranding returns only the effective application brand for
// the active licensed runtime. The route itself is monitoring:read so the
// header can render consistently for non-admin viewers.
func (h *SystemSettingsHandler) HandleGetRuntimeBranding(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil)
return
}
service := getLicenseServiceForContext(r.Context())
entitled := service != nil && service.HasFeature(featureWhiteLabelValue)
if !entitled {
_ = utils.WriteJSONResponse(w, emptyRuntimeBrandingResponse())
return
}
if h == nil || h.persistence == nil {
_ = utils.WriteJSONResponse(w, emptyRuntimeBrandingResponse())
return
}
settings, err := h.persistence.LoadSystemSettings()
if err != nil {
log.Warn().Err(err).Msg("Failed to load runtime branding settings")
_ = utils.WriteJSONResponse(w, emptyRuntimeBrandingResponse())
return
}
var configured *config.ReportBrandSettings
if settings != nil {
configured = settings.ReportBranding
}
if err := utils.WriteJSONResponse(w, runtimeBrandingResponse(configured, true)); err != nil {
log.Error().Err(err).Msg("Failed to write runtime branding response")
}
}
+126
View File
@@ -0,0 +1,126 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
func TestRuntimeBrandingResponseRequiresEntitlement(t *testing.T) {
settings := &config.ReportBrandSettings{
DisplayName: "Acme Operations",
LogoBase64: base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n")),
LogoFormat: "png",
}
got := runtimeBrandingResponse(settings, false)
if got.Enabled || got.DisplayName != "" || got.LogoDataURL != "" {
t.Fatalf("unentitled branding leaked into runtime response: %+v", got)
}
}
func TestRuntimeBrandingResponseBuildsCanonicalPresentationPayload(t *testing.T) {
png := []byte("\x89PNG\r\n\x1a\n")
settings := &config.ReportBrandSettings{
DisplayName: " Acme Operations ",
LogoBase64: "data:image/png;base64," + base64.StdEncoding.EncodeToString(png),
}
got := runtimeBrandingResponse(settings, true)
if !got.Enabled {
t.Fatal("expected configured entitled branding to be enabled")
}
if got.DisplayName != "Acme Operations" {
t.Fatalf("displayName = %q, want trimmed brand", got.DisplayName)
}
if !strings.HasPrefix(got.LogoDataURL, "data:image/png;base64,") {
t.Fatalf("logoDataUrl = %q, want canonical PNG data URL", got.LogoDataURL)
}
}
func TestRuntimeBrandingResponseKeepsNameWhenLogoIsNotAnImage(t *testing.T) {
settings := &config.ReportBrandSettings{
DisplayName: "Acme",
LogoBase64: base64.StdEncoding.EncodeToString([]byte("not an image")),
}
got := runtimeBrandingResponse(settings, true)
if !got.Enabled || got.DisplayName != "Acme" || got.LogoDataURL != "" {
t.Fatalf("unexpected name-only runtime branding: %+v", got)
}
}
func TestRuntimeBrandingResponseRejectsMismatchedImageFormat(t *testing.T) {
settings := &config.ReportBrandSettings{
DisplayName: "Acme",
LogoBase64: base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n")),
LogoFormat: "gif",
}
got := runtimeBrandingResponse(settings, true)
if !got.Enabled || got.DisplayName != "Acme" || got.LogoDataURL != "" {
t.Fatalf("mismatched image format must be omitted: %+v", got)
}
}
func TestHandleGetRuntimeBrandingRejectsUnsupportedMethod(t *testing.T) {
handler := &SystemSettingsHandler{}
request := httptest.NewRequest(http.MethodPost, "/api/runtime/branding", nil)
response := httptest.NewRecorder()
handler.HandleGetRuntimeBranding(response, request)
if response.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want %d", response.Code, http.StatusMethodNotAllowed)
}
}
func TestHandleGetRuntimeBrandingReturnsPersistedBrandForEntitledRuntime(t *testing.T) {
service := pkglicensing.NewService()
service.SetCurrentForTesting(&pkglicensing.License{
Claims: pkglicensing.Claims{
LicenseID: "lic_runtime_branding",
Email: "brand@example.test",
Tier: pkglicensing.TierEnterprise,
},
ValidatedAt: time.Now(),
})
SetLicenseServiceProvider(reportBrandLicenseProvider{service: service})
t.Cleanup(func() { SetLicenseServiceProvider(nil) })
persistence := config.NewConfigPersistence(t.TempDir())
settings := config.DefaultSystemSettings()
settings.ReportBranding = &config.ReportBrandSettings{
DisplayName: "Acme Operations",
LogoBase64: base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n")),
LogoFormat: "png",
}
if err := persistence.SaveSystemSettings(*settings); err != nil {
t.Fatalf("save system settings: %v", err)
}
handler := &SystemSettingsHandler{persistence: persistence}
request := httptest.NewRequest(http.MethodGet, "/api/runtime/branding", nil)
response := httptest.NewRecorder()
handler.HandleGetRuntimeBranding(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", response.Code, http.StatusOK, response.Body.String())
}
var got RuntimeBrandingResponse
if err := json.NewDecoder(response.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
if !got.Enabled || got.DisplayName != "Acme Operations" ||
!strings.HasPrefix(got.LogoDataURL, "data:image/png;base64,") {
t.Fatalf("unexpected runtime branding response: %+v", got)
}
}
+1
View File
@@ -3050,6 +3050,7 @@ func TestMonitoringReadEndpointsRequireMonitoringReadScope(t *testing.T) {
paths := []string{
"/api/config",
"/api/runtime/branding",
"/api/storage/host-1",
"/api/storage-charts",
"/api/charts",