mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
feat(branding): customize application identity
This commit is contained in:
+7
-1
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
+11
@@ -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,
|
||||
|
||||
+3
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user