mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 22:12:23 +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
|
model each client runtime has its own settings, so the override is
|
||||||
per-client; in shared-process mode the settings override applies
|
per-client; in shared-process mode the settings override applies
|
||||||
instance-wide, so all organizations share one brand (usually yours). Branding
|
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
|
Scheduled reports are tenant-local. In provider-hosted MSP, each client
|
||||||
runtime stores its own schedules in `report_schedules.json`, writes generated
|
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,
|
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
|
the trusted-proxy gate on forwarded headers, and the empty-rather-than-wrong
|
||||||
behavior.
|
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
|
localhost literal, and the corrected post-save copy; the rendered fallbacks and
|
||||||
copy affordances are covered by
|
copy affordances are covered by
|
||||||
`frontend-modern/src/components/Settings/__tests__/SSOProvidersPanel.test.tsx`.
|
`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
|
Regression coverage: the `Issue1650` cases in
|
||||||
`frontend-modern/src/utils/__tests__/securityScorePresentation.test.ts` and
|
`frontend-modern/src/utils/__tests__/securityScorePresentation.test.ts` and
|
||||||
`frontend-modern/src/components/__tests__/SecurityWarning.test.tsx`.
|
`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
|
handoff, demo organization suppression, and platform-page route carriage in
|
||||||
`frontend-modern/src/App.tsx` are unchanged; nothing about backup, restore, or
|
`frontend-modern/src/App.tsx` are unchanged; nothing about backup, restore, or
|
||||||
recovery routing keys off banner visibility.
|
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 { actionInboxStore } from '@/stores/actionInbox';
|
||||||
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
||||||
import { isPro } from '@/stores/licenseCommercial';
|
import { isPro } from '@/stores/licenseCommercial';
|
||||||
|
import { runtimeBranding } from '@/stores/systemSettings';
|
||||||
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
|
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
|
||||||
import { getAssistantPageContext } from '@/utils/assistantPageContext';
|
import { getAssistantPageContext } from '@/utils/assistantPageContext';
|
||||||
import type { AppConnectionStatus } from '@/useAppRuntimeState';
|
import type { AppConnectionStatus } from '@/useAppRuntimeState';
|
||||||
@@ -235,6 +236,13 @@ export function AppLayout(props: AppLayoutProps) {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const kioskMode = useKioskMode();
|
const kioskMode = useKioskMode();
|
||||||
const brandMotionActive = createMemo(() => props.connectionStatus().tone === 'healthy');
|
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 [headerVisible, setHeaderVisible] = createSignal(true);
|
||||||
const [skipLinkFocused, setSkipLinkFocused] = createSignal(false);
|
const [skipLinkFocused, setSkipLinkFocused] = createSignal(false);
|
||||||
@@ -290,7 +298,7 @@ export function AppLayout(props: AppLayoutProps) {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const active = getActiveTabForPath(location.pathname);
|
const active = getActiveTabForPath(location.pathname);
|
||||||
if (!active) {
|
if (!active) {
|
||||||
document.title = 'Pulse';
|
document.title = browserBrandName();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The standalone (Machines) section has sub-tabs with their own labels.
|
// 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").
|
// the actual page (e.g. "Availability checks" not "Machines").
|
||||||
if (active === 'standalone') {
|
if (active === 'standalone') {
|
||||||
const subTab = resolveStandaloneSubTabTitle(location.pathname);
|
const subTab = resolveStandaloneSubTabTitle(location.pathname);
|
||||||
document.title = `${subTab} · Pulse`;
|
document.title = `${subTab} · ${browserBrandName()}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.title = `${tabTitleByActive[active]} · Pulse`;
|
document.title = `${tabTitleByActive[active]} · ${browserBrandName()}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleKioskMode = () => {
|
const toggleKioskMode = () => {
|
||||||
@@ -754,37 +762,55 @@ export function AppLayout(props: AppLayoutProps) {
|
|||||||
<Show when={!kioskMode()}>
|
<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="flex items-center gap-2 sm:flex-initial sm:gap-2 sm:col-start-2 sm:col-end-3 sm:justify-self-center">
|
||||||
<div
|
<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"
|
data-testid="pulse-brand-lockup"
|
||||||
>
|
>
|
||||||
<svg
|
<Show
|
||||||
width="20"
|
when={customBrandLogo()}
|
||||||
height="20"
|
fallback={
|
||||||
viewBox="0 0 256 256"
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
width="20"
|
||||||
class="pulse-brand-logo"
|
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>
|
{(logoDataUrl) => (
|
||||||
<circle
|
<img
|
||||||
class="pulse-bg fill-blue-600 dark:fill-blue-500"
|
src={logoDataUrl()}
|
||||||
cx="128"
|
alt={customBrandName() ? `${customBrandName()} logo` : 'Custom logo'}
|
||||||
cy="128"
|
class="max-h-8 max-w-[12rem] object-contain"
|
||||||
r="122"
|
data-testid="custom-brand-logo"
|
||||||
/>
|
/>
|
||||||
<circle
|
)}
|
||||||
class="pulse-ring fill-none stroke-white stroke-[14] opacity-[0.92]"
|
</Show>
|
||||||
cx="128"
|
<Show when={customBrandName() || !customBrandLogo()}>
|
||||||
cy="128"
|
<span class="pulse-brand-wordmark text-lg font-medium text-base-content">
|
||||||
r="84"
|
{customBrandName() || 'Pulse'}
|
||||||
/>
|
</span>
|
||||||
<circle
|
</Show>
|
||||||
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>
|
|
||||||
<Show when={props.versionInfo()?.channel === 'rc'}>
|
<Show when={props.versionInfo()?.channel === 'rc'}>
|
||||||
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
|
<span class="text-xs px-1.5 py-0.5 bg-orange-500 text-white rounded font-bold">
|
||||||
Preview
|
Preview
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
import { isKioskMode, setKioskMode } from '@/utils/url';
|
import { isKioskMode, setKioskMode } from '@/utils/url';
|
||||||
import type { PlatformNavigationVisibility } from '@/features/platformNavigation/platformNavigationModel';
|
import type { PlatformNavigationVisibility } from '@/features/platformNavigation/platformNavigationModel';
|
||||||
import { aiChatStore } from '@/stores/aiChat';
|
import { aiChatStore } from '@/stores/aiChat';
|
||||||
|
import {
|
||||||
|
clearRuntimeBranding,
|
||||||
|
updateRuntimeBrandingFromResponse,
|
||||||
|
} from '@/stores/systemSettings';
|
||||||
|
|
||||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
window.scrollTo = vi.fn();
|
window.scrollTo = vi.fn();
|
||||||
@@ -117,6 +121,7 @@ describe('AppLayout navigation icons', () => {
|
|||||||
window.history.replaceState({}, '', '/settings/infrastructure');
|
window.history.replaceState({}, '', '/settings/infrastructure');
|
||||||
resetPrimaryNavigationRouteMemory();
|
resetPrimaryNavigationRouteMemory();
|
||||||
patrolAttentionMockState.activeCount = 0;
|
patrolAttentionMockState.activeCount = 0;
|
||||||
|
clearRuntimeBranding();
|
||||||
aiChatStore.close();
|
aiChatStore.close();
|
||||||
aiChatStore.setEnabled(true);
|
aiChatStore.setEnabled(true);
|
||||||
});
|
});
|
||||||
@@ -124,6 +129,7 @@ describe('AppLayout navigation icons', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
aiChatStore.close();
|
aiChatStore.close();
|
||||||
aiChatStore.setEnabled(false);
|
aiChatStore.setEnabled(false);
|
||||||
|
clearRuntimeBranding();
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,6 +340,24 @@ describe('AppLayout navigation icons', () => {
|
|||||||
expect(container.querySelector('.animate-pulse-logo')).toBeNull();
|
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', () => {
|
it('keeps the assistant launcher clear of the mobile navigation breakpoint', () => {
|
||||||
renderLayout();
|
renderLayout();
|
||||||
|
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ describe('useAppRuntimeState', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock('@/stores/systemSettings', () => ({
|
vi.doMock('@/stores/systemSettings', () => ({
|
||||||
|
loadRuntimeBranding: vi.fn().mockResolvedValue(undefined),
|
||||||
markSystemSettingsLoadedWithDefaults: vi.fn(),
|
markSystemSettingsLoadedWithDefaults: vi.fn(),
|
||||||
updateSystemSettingsFromResponse: 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', () => {
|
describe('getTelemetryPreview', () => {
|
||||||
it('fetches the telemetry preview payload', async () => {
|
it('fetches the telemetry preview payload', async () => {
|
||||||
const mockPreview = {
|
const mockPreview = {
|
||||||
|
|||||||
@@ -156,6 +156,12 @@ export interface TelemetryPreviewResponse {
|
|||||||
payload: TelemetryPingPreview;
|
payload: TelemetryPingPreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuntimeBrandingResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
displayName: string;
|
||||||
|
logoDataUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SettingsAPI {
|
export class SettingsAPI {
|
||||||
private static baseUrl = '/api';
|
private static baseUrl = '/api';
|
||||||
|
|
||||||
@@ -172,6 +178,10 @@ export class SettingsAPI {
|
|||||||
return apiFetchJSON(`${this.baseUrl}/system/settings`) as Promise<SystemSettingsResponse>;
|
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> {
|
static async getTelemetryPreview(): Promise<TelemetryPreviewResponse> {
|
||||||
return apiFetchJSON(
|
return apiFetchJSON(
|
||||||
`${this.baseUrl}/system/settings/telemetry-preview`,
|
`${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 { FilterButtonGroup, type FilterOption } from '@/components/shared/FilterButtonGroup';
|
||||||
import type { TelemetryPreviewResponse } from '@/api/settings';
|
import type { TelemetryPreviewResponse } from '@/api/settings';
|
||||||
import { DockerRuntimeSettingsCard } from './DockerRuntimeSettingsCard';
|
import { DockerRuntimeSettingsCard } from './DockerRuntimeSettingsCard';
|
||||||
|
import { BrandingSettingsCard } from './BrandingSettingsCard';
|
||||||
|
import type { ReportBrandSettings } from '@/types/config';
|
||||||
import Sun from 'lucide-solid/icons/sun';
|
import Sun from 'lucide-solid/icons/sun';
|
||||||
import Moon from 'lucide-solid/icons/moon';
|
import Moon from 'lucide-solid/icons/moon';
|
||||||
import Languages from 'lucide-solid/icons/languages';
|
import Languages from 'lucide-solid/icons/languages';
|
||||||
@@ -70,6 +72,12 @@ export interface GeneralSettingsPanelProps {
|
|||||||
setPVEPollingCustomSeconds: Setter<number>;
|
setPVEPollingCustomSeconds: Setter<number>;
|
||||||
pvePollingEnvLocked: () => boolean;
|
pvePollingEnvLocked: () => boolean;
|
||||||
setHasUnsavedChanges: Setter<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>;
|
telemetryEnabled: Accessor<boolean>;
|
||||||
telemetryEnabledLocked: () => boolean;
|
telemetryEnabledLocked: () => boolean;
|
||||||
@@ -212,6 +220,18 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
|
|||||||
onChange={() => layoutStore.toggle()}
|
onChange={() => layoutStore.toggle()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</SettingsPanel>
|
||||||
|
|
||||||
{/* Usage Data + Privacy Card */}
|
{/* 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 [, setHasUnsavedChanges] = createSignal(false);
|
||||||
const [telemetryEnabled] = createSignal(true);
|
const [telemetryEnabled] = createSignal(true);
|
||||||
const [disableDockerUpdateActions] = createSignal(false);
|
const [disableDockerUpdateActions] = createSignal(false);
|
||||||
|
const [reportBrandDisplayName, setReportBrandDisplayName] = createSignal('');
|
||||||
|
const [reportBrandLogoBase64, setReportBrandLogoBase64] = createSignal('');
|
||||||
|
const [reportBrandLogoFormat, setReportBrandLogoFormat] = createSignal<
|
||||||
|
'' | 'png' | 'jpg' | 'jpeg' | 'gif'
|
||||||
|
>('');
|
||||||
|
|
||||||
const props: GeneralSettingsPanelProps = {
|
const props: GeneralSettingsPanelProps = {
|
||||||
darkMode: () => false,
|
darkMode: () => false,
|
||||||
@@ -26,6 +31,12 @@ function renderGeneralSettingsPanel(overrides: Partial<GeneralSettingsPanelProps
|
|||||||
setPVEPollingCustomSeconds,
|
setPVEPollingCustomSeconds,
|
||||||
pvePollingEnvLocked: () => false,
|
pvePollingEnvLocked: () => false,
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
|
reportBrandDisplayName,
|
||||||
|
setReportBrandDisplayName,
|
||||||
|
reportBrandLogoBase64,
|
||||||
|
setReportBrandLogoBase64,
|
||||||
|
reportBrandLogoFormat,
|
||||||
|
setReportBrandLogoFormat,
|
||||||
telemetryEnabled,
|
telemetryEnabled,
|
||||||
telemetryEnabledLocked: () => false,
|
telemetryEnabledLocked: () => false,
|
||||||
savingTelemetry: () => false,
|
savingTelemetry: () => false,
|
||||||
|
|||||||
+3
@@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
updateStoreIsDismissedMock: vi.fn(),
|
updateStoreIsDismissedMock: vi.fn(),
|
||||||
updateStoreClearDismissedMock: vi.fn(),
|
updateStoreClearDismissedMock: vi.fn(),
|
||||||
updateDockerUpdateActionsSettingMock: vi.fn(),
|
updateDockerUpdateActionsSettingMock: vi.fn(),
|
||||||
|
loadRuntimeBrandingMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/api/settings', () => ({
|
vi.mock('@/api/settings', () => ({
|
||||||
@@ -86,6 +87,7 @@ vi.mock('@/stores/updates', () => ({
|
|||||||
|
|
||||||
vi.mock('@/stores/systemSettings', () => ({
|
vi.mock('@/stores/systemSettings', () => ({
|
||||||
updateDockerUpdateActionsSetting: mocks.updateDockerUpdateActionsSettingMock,
|
updateDockerUpdateActionsSetting: mocks.updateDockerUpdateActionsSettingMock,
|
||||||
|
loadRuntimeBranding: mocks.loadRuntimeBrandingMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type HookState = ReturnType<typeof useSystemSettingsState>;
|
type HookState = ReturnType<typeof useSystemSettingsState>;
|
||||||
@@ -99,6 +101,7 @@ describe('useSystemSettingsState branch coverage', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.getSystemSettingsMock.mockResolvedValue({});
|
mocks.getSystemSettingsMock.mockResolvedValue({});
|
||||||
mocks.updateSystemSettingsMock.mockResolvedValue(undefined);
|
mocks.updateSystemSettingsMock.mockResolvedValue(undefined);
|
||||||
|
mocks.loadRuntimeBrandingMock.mockResolvedValue(undefined);
|
||||||
mocks.getUpdatePlanMock.mockResolvedValue({
|
mocks.getUpdatePlanMock.mockResolvedValue({
|
||||||
canAutoUpdate: false,
|
canAutoUpdate: false,
|
||||||
requiresRoot: false,
|
requiresRoot: false,
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ describe('useSystemSettingsState', () => {
|
|||||||
|
|
||||||
vi.doMock('@/stores/systemSettings', () => ({
|
vi.doMock('@/stores/systemSettings', () => ({
|
||||||
updateDockerUpdateActionsSetting: vi.fn(),
|
updateDockerUpdateActionsSetting: vi.fn(),
|
||||||
|
loadRuntimeBranding: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
({ useSystemSettingsState } = await import('../useSystemSettingsState'));
|
({ useSystemSettingsState } = await import('../useSystemSettingsState'));
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export function useSettingsSystemPanels(
|
|||||||
setPVEPollingCustomSeconds: params.systemSettings.setPVEPollingCustomSeconds,
|
setPVEPollingCustomSeconds: params.systemSettings.setPVEPollingCustomSeconds,
|
||||||
pvePollingEnvLocked: params.systemSettings.pvePollingEnvLocked,
|
pvePollingEnvLocked: params.systemSettings.pvePollingEnvLocked,
|
||||||
setHasUnsavedChanges: params.systemSettings.setHasUnsavedChanges,
|
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,
|
telemetryEnabled: params.systemSettings.telemetryEnabled,
|
||||||
telemetryEnabledLocked: params.systemSettings.telemetryEnabledLocked,
|
telemetryEnabledLocked: params.systemSettings.telemetryEnabledLocked,
|
||||||
savingTelemetry: params.systemSettings.savingTelemetry,
|
savingTelemetry: params.systemSettings.savingTelemetry,
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { notificationStore } from '@/stores/notifications';
|
|||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { updateStore } from '@/stores/updates';
|
import { updateStore } from '@/stores/updates';
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
import { copyToClipboard } from '@/utils/clipboard';
|
||||||
import { updateDockerUpdateActionsSetting } from '@/stores/systemSettings';
|
import {
|
||||||
|
loadRuntimeBranding,
|
||||||
|
updateDockerUpdateActionsSetting,
|
||||||
|
} from '@/stores/systemSettings';
|
||||||
|
import type { ReportBrandSettings } from '@/types/config';
|
||||||
import {
|
import {
|
||||||
BACKUP_INTERVAL_OPTIONS,
|
BACKUP_INTERVAL_OPTIONS,
|
||||||
getCheckForUpdatesErrorMessage,
|
getCheckForUpdatesErrorMessage,
|
||||||
@@ -58,6 +62,10 @@ export function useSystemSettingsState({
|
|||||||
const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false);
|
const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false);
|
||||||
const [savingDockerUpdateActions, setSavingDockerUpdateActions] = createSignal(false);
|
const [savingDockerUpdateActions, setSavingDockerUpdateActions] = createSignal(false);
|
||||||
const [telemetryEnabled, setTelemetryEnabled] = createSignal(true);
|
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 [savingTelemetry, setSavingTelemetry] = createSignal(false);
|
||||||
const [telemetryPreview, setTelemetryPreview] = createSignal<TelemetryPreviewResponse | null>(
|
const [telemetryPreview, setTelemetryPreview] = createSignal<TelemetryPreviewResponse | null>(
|
||||||
null,
|
null,
|
||||||
@@ -141,6 +149,9 @@ export function useSystemSettingsState({
|
|||||||
setHideLocalLogin(systemSettings.hideLocalLogin ?? false);
|
setHideLocalLogin(systemSettings.hideLocalLogin ?? false);
|
||||||
setDisableDockerUpdateActions(systemSettings.disableDockerUpdateActions ?? false);
|
setDisableDockerUpdateActions(systemSettings.disableDockerUpdateActions ?? false);
|
||||||
setTelemetryEnabled(systemSettings.telemetryEnabled ?? true);
|
setTelemetryEnabled(systemSettings.telemetryEnabled ?? true);
|
||||||
|
setReportBrandDisplayName(systemSettings.reportBranding?.displayName ?? '');
|
||||||
|
setReportBrandLogoBase64(systemSettings.reportBranding?.logoBase64 ?? '');
|
||||||
|
setReportBrandLogoFormat(systemSettings.reportBranding?.logoFormat ?? '');
|
||||||
|
|
||||||
if (typeof systemSettings.backupPollingEnabled === 'boolean') {
|
if (typeof systemSettings.backupPollingEnabled === 'boolean') {
|
||||||
setBackupPollingEnabled(systemSettings.backupPollingEnabled);
|
setBackupPollingEnabled(systemSettings.backupPollingEnabled);
|
||||||
@@ -221,7 +232,13 @@ export function useSystemSettingsState({
|
|||||||
allowedEmbedOrigins: allowedEmbedOrigins(),
|
allowedEmbedOrigins: allowedEmbedOrigins(),
|
||||||
webhookAllowedPrivateCIDRs: webhookAllowedPrivateCIDRs(),
|
webhookAllowedPrivateCIDRs: webhookAllowedPrivateCIDRs(),
|
||||||
publicURL: publicURL(),
|
publicURL: publicURL(),
|
||||||
|
reportBranding: {
|
||||||
|
displayName: reportBrandDisplayName().trim(),
|
||||||
|
logoBase64: reportBrandLogoBase64(),
|
||||||
|
logoFormat: reportBrandLogoFormat(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
await loadRuntimeBranding();
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNetworkTab = initiatingTab === 'system-network';
|
const isNetworkTab = initiatingTab === 'system-network';
|
||||||
@@ -502,6 +519,12 @@ export function useSystemSettingsState({
|
|||||||
savingDockerUpdateActions,
|
savingDockerUpdateActions,
|
||||||
handleDisableDockerUpdateActionsChange,
|
handleDisableDockerUpdateActionsChange,
|
||||||
telemetryEnabled,
|
telemetryEnabled,
|
||||||
|
reportBrandDisplayName,
|
||||||
|
setReportBrandDisplayName,
|
||||||
|
reportBrandLogoBase64,
|
||||||
|
setReportBrandLogoBase64,
|
||||||
|
reportBrandLogoFormat,
|
||||||
|
setReportBrandLogoFormat,
|
||||||
telemetryEnabledLocked,
|
telemetryEnabledLocked,
|
||||||
savingTelemetry,
|
savingTelemetry,
|
||||||
handleTelemetryEnabledChange,
|
handleTelemetryEnabledChange,
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import { PRIVACY_DOC_URL } from '@/utils/docsLinks';
|
|||||||
import { EN_MESSAGES } from '@/i18n/messages';
|
import { EN_MESSAGES } from '@/i18n/messages';
|
||||||
import {
|
import {
|
||||||
areSystemSettingsLoaded,
|
areSystemSettingsLoaded,
|
||||||
|
clearRuntimeBranding,
|
||||||
markSystemSettingsLoadedWithDefaults,
|
markSystemSettingsLoadedWithDefaults,
|
||||||
|
runtimeBranding,
|
||||||
shouldHideDockerUpdateActions,
|
shouldHideDockerUpdateActions,
|
||||||
shouldReduceProUpsellNoise,
|
shouldReduceProUpsellNoise,
|
||||||
|
updateRuntimeBrandingFromResponse,
|
||||||
updateSystemSettingsFromResponse,
|
updateSystemSettingsFromResponse,
|
||||||
} from '@/stores/systemSettings';
|
} from '@/stores/systemSettings';
|
||||||
|
|
||||||
@@ -20,6 +23,7 @@ const repoRoot = path.resolve(frontendRoot, '..');
|
|||||||
describe('systemSettings store', () => {
|
describe('systemSettings store', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
markSystemSettingsLoadedWithDefaults();
|
markSystemSettingsLoadedWithDefaults();
|
||||||
|
clearRuntimeBranding();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies route and docker feature flags from API response', () => {
|
it('applies route and docker feature flags from API response', () => {
|
||||||
@@ -55,6 +59,33 @@ describe('systemSettings store', () => {
|
|||||||
expect(shouldReduceProUpsellNoise()).toBe(false);
|
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', () => {
|
it('keeps the telemetry disclosure on the shipped local privacy doc', () => {
|
||||||
expect(PRIVACY_DOC_URL).toBe('/docs/PRIVACY.md');
|
expect(PRIVACY_DOC_URL).toBe('/docs/PRIVACY.md');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from 'solid-js';
|
import { createSignal } from 'solid-js';
|
||||||
import { SettingsAPI } from '@/api/settings';
|
import { SettingsAPI, type RuntimeBrandingResponse } from '@/api/settings';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import type { SystemConfig } from '@/types/config';
|
import type { SystemConfig } from '@/types/config';
|
||||||
|
|
||||||
@@ -14,6 +14,11 @@ import type { SystemConfig } from '@/types/config';
|
|||||||
const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false);
|
const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false);
|
||||||
// Server-side compatibility setting for proactive commercial prompts
|
// Server-side compatibility setting for proactive commercial prompts
|
||||||
const [reduceProUpsellNoise, setReduceProUpsellNoise] = createSignal(false);
|
const [reduceProUpsellNoise, setReduceProUpsellNoise] = createSignal(false);
|
||||||
|
const [runtimeBranding, setRuntimeBranding] = createSignal<RuntimeBrandingResponse>({
|
||||||
|
enabled: false,
|
||||||
|
displayName: '',
|
||||||
|
logoDataUrl: '',
|
||||||
|
});
|
||||||
|
|
||||||
// Track if settings have been loaded
|
// Track if settings have been loaded
|
||||||
const [systemSettingsLoaded, setSystemSettingsLoaded] = createSignal(false);
|
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.
|
* Check if Docker update actions (buttons) should be hidden.
|
||||||
* Returns true if the server has configured to hide update buttons.
|
* 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 {
|
export function updateReduceProUpsellNoiseSetting(enabled: boolean): void {
|
||||||
setReduceProUpsellNoise(enabled);
|
setReduceProUpsellNoise(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { runtimeBranding };
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export interface AuthConfig {
|
|||||||
*/
|
*/
|
||||||
export type UpdateChannel = 'stable' | 'rc';
|
export type UpdateChannel = 'stable' | 'rc';
|
||||||
|
|
||||||
|
export interface ReportBrandSettings {
|
||||||
|
displayName?: string;
|
||||||
|
logoBase64?: string;
|
||||||
|
logoFormat?: 'png' | 'jpg' | 'jpeg' | 'gif' | '';
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemConfig {
|
export interface SystemConfig {
|
||||||
pvePollingInterval?: number; // PVE polling interval in seconds
|
pvePollingInterval?: number; // PVE polling interval in seconds
|
||||||
pbsPollingInterval?: number; // PBS 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)
|
disableDockerUpdateActions?: boolean; // Hide Docker update buttons while still detecting updates (server-wide)
|
||||||
reduceProUpsellNoise?: boolean; // Legacy compatibility preference for proactive commercial prompts
|
reduceProUpsellNoise?: boolean; // Legacy compatibility preference for proactive commercial prompts
|
||||||
telemetryEnabled?: boolean; // Outbound usage telemetry, enabled by default unless disabled
|
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';
|
} from '@/stores/sessionPresentationPolicy';
|
||||||
import { layoutStore } from '@/utils/layout';
|
import { layoutStore } from '@/utils/layout';
|
||||||
import {
|
import {
|
||||||
|
loadRuntimeBranding,
|
||||||
markSystemSettingsLoadedWithDefaults,
|
markSystemSettingsLoadedWithDefaults,
|
||||||
updateSystemSettingsFromResponse,
|
updateSystemSettingsFromResponse,
|
||||||
} from '@/stores/systemSettings';
|
} from '@/stores/systemSettings';
|
||||||
@@ -370,7 +371,7 @@ export const useAppRuntimeState = () => {
|
|||||||
await loadOrganizations();
|
await loadOrganizations();
|
||||||
setWsStore(acquireWsStore());
|
setWsStore(acquireWsStore());
|
||||||
setBackendHealthy(true);
|
setBackendHealthy(true);
|
||||||
await loadSystemSettingsAndLayout();
|
await Promise.all([loadSystemSettingsAndLayout(), loadRuntimeBranding()]);
|
||||||
// Shared commercial posture stays off ordinary self-hosted app shells.
|
// Shared commercial posture stays off ordinary self-hosted app shells.
|
||||||
if (!presentationPolicyHidesUpgradePrompts()) {
|
if (!presentationPolicyHidesUpgradePrompts()) {
|
||||||
void loadCommercialPosture();
|
void loadCommercialPosture();
|
||||||
|
|||||||
@@ -484,6 +484,7 @@ var allRouteAllowlist = []string{
|
|||||||
"/api/vmware/connections/",
|
"/api/vmware/connections/",
|
||||||
"/api/admin/profiles/",
|
"/api/admin/profiles/",
|
||||||
"/api/config/system",
|
"/api/config/system",
|
||||||
|
"/api/runtime/branding",
|
||||||
"/api/system/settings/telemetry-preview",
|
"/api/system/settings/telemetry-preview",
|
||||||
"/api/system/settings/telemetry-reset-id",
|
"/api/system/settings/telemetry-reset-id",
|
||||||
"/api/system/mock-mode",
|
"/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", 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/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/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
|
// Update routes
|
||||||
r.mux.HandleFunc("/api/updates/check", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, updateHandlers.HandleCheckUpdates)))
|
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)))
|
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{
|
paths := []string{
|
||||||
"/api/config",
|
"/api/config",
|
||||||
|
"/api/runtime/branding",
|
||||||
"/api/storage/host-1",
|
"/api/storage/host-1",
|
||||||
"/api/storage-charts",
|
"/api/storage-charts",
|
||||||
"/api/charts",
|
"/api/charts",
|
||||||
|
|||||||
Reference in New Issue
Block a user