Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions frontend/src/__tests__/ThemeProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,25 @@ describe('ThemeProvider', () => {
expect(document.documentElement.getAttribute('data-org-name')).toBe('Acme Corp');
expect(document.documentElement.style.getPropertyValue('--brand-primary')).toBe('#ff0055');

expect(document.documentElement.style.getPropertyValue('--brand-on-primary')).toBe('#ffffff');

await user.click(screen.getByRole('button', { name: /reset-brand/i }));
expect(screen.getByTestId('org-name')).toHaveTextContent('none');
expect(document.documentElement.getAttribute('data-org-name')).toBeNull();
expect(document.documentElement.style.getPropertyValue('--brand-primary')).toBe('');
});

test('ignores non-hex brand colours instead of writing them into CSS', () => {
localStorage.setItem(
'payd-org-brand',
JSON.stringify({ primaryColor: 'red; background:url(x)', accentColor: '#14b8a6' })
);
render(
<ThemeProvider>
<BrandProbe />
</ThemeProvider>
);
expect(document.documentElement.style.getPropertyValue('--brand-primary')).toBe('');
expect(document.documentElement.style.getPropertyValue('--brand-accent')).toBe('#14b8a6');
});
});
8 changes: 2 additions & 6 deletions frontend/src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import { Outlet, NavLink, useLocation } from 'react-router-dom';
import ConnectAccount from '../components/ConnectAccount';
import AppNav from './AppNav';
import { BrandLogo } from './BrandLogo';
import { LanguageSelector } from './LanguageSelector';
import { ThemeToggle } from './ThemeToggle';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -45,12 +46,7 @@ const AppLayout: React.FC = () => {
className="flex items-center gap-2.5 rounded-lg outline-none motion-safe:transition-transform motion-safe:duration-(--motion-duration-fast) motion-safe:ease-[var(--motion-ease-out)] hover:scale-[1.03] active:scale-[0.97] focus-visible:ring-2 focus-visible:ring-(--accent)/50"
to="/"
>
<div className="w-8 h-8 rounded-lg grid place-items-center font-extrabold text-black text-sm tracking-tight shadow-[0_0_20px_color-mix(in_srgb,var(--accent)_30%,transparent)] bg-linear-to-br from-(--accent) to-(--accent2)">
P
</div>
<span className="text-lg font-extrabold tracking-tight">
Pay<span className="text-(--accent)">D</span>
</span>
<BrandLogo />
<span className="text-[9px] font-normal font-mono text-(--muted) tracking-widest uppercase border border-(--border-hi) px-1.5 py-0.5 rounded ml-0.5">
BETA
</span>
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/BrandLogo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import React, { useState } from 'react';
import { useTheme } from '../hooks/useTheme';
import { sanitizeLogoUrl, sanitizeOrgName } from '../utils/brand';

/**
* Header logo: the organization's logo/name when branding is configured
* (#1505), otherwise the default PayD wordmark.
*/
export const BrandLogo: React.FC = () => {
const { brandConfig } = useTheme();
const logoUrl = sanitizeLogoUrl(brandConfig.logoUrl);
const orgName = sanitizeOrgName(brandConfig.orgName);
const [logoFailed, setLogoFailed] = useState(false);

if ((logoUrl && !logoFailed) || orgName) {
return (
<span className="flex items-center gap-2.5 min-w-0">
{logoUrl && !logoFailed && (
<img
src={logoUrl}
alt={orgName ? `${orgName} logo` : 'Organization logo'}
className="h-8 w-auto max-w-[120px] object-contain"
onError={() => setLogoFailed(true)}
/>
)}
{orgName && (
<span className="text-lg font-extrabold tracking-tight truncate max-w-[40vw] sm:max-w-[240px]">
{orgName}
</span>
)}
<span className="sr-only">, powered by PayD</span>
</span>
);
}

return (
<>
<div className="w-8 h-8 rounded-lg grid place-items-center font-extrabold text-black text-sm tracking-tight shadow-[0_0_20px_color-mix(in_srgb,var(--accent)_30%,transparent)] bg-linear-to-br from-(--accent) to-(--accent2)">
P
</div>
<span className="text-lg font-extrabold tracking-tight">
Pay<span className="text-(--accent)">D</span>
</span>
</>
);
};

export default BrandLogo;
161 changes: 161 additions & 0 deletions frontend/src/components/BrandingSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React, { useId, useState } from 'react';
import { Palette, RotateCcw } from 'lucide-react';
import { useTheme } from '../hooks/useTheme';
import {
AA_CONTRAST,
contrastRatio,
isValidHexColor,
normalizeHexColor,
readableTextOn,
sanitizeLogoUrl,
sanitizeOrgName,
} from '../utils/brand';

const DEFAULT_PRIMARY = '#0a2540';
const DEFAULT_ACCENT = '#14b8a6';

/**
* Organization branding (#1505): logo, name, primary and accent colour.
* Values are validated, previewed live, and applied app-wide via
* ThemeProvider's --brand-* tokens in both light and dark mode.
*/
export const BrandingSettings: React.FC = () => {
const { brandConfig, setBrandConfig, resetBrandConfig } = useTheme();
const ids = { name: useId(), logo: useId(), primary: useId(), accent: useId(), err: useId() };

const [orgName, setOrgName] = useState(brandConfig.orgName ?? '');
const [logoUrl, setLogoUrl] = useState(brandConfig.logoUrl ?? '');
const [primary, setPrimary] = useState(brandConfig.primaryColor ?? DEFAULT_PRIMARY);
const [accent, setAccent] = useState(brandConfig.accentColor ?? DEFAULT_ACCENT);
const [saved, setSaved] = useState(false);

const logoInvalid = logoUrl.trim() !== '' && sanitizeLogoUrl(logoUrl) === null;
const primaryInvalid = !isValidHexColor(primary);
const accentInvalid = !isValidHexColor(accent);
const hasErrors = logoInvalid || primaryInvalid || accentInvalid;

const previewPrimary = normalizeHexColor(primary) ?? DEFAULT_PRIMARY;
const onPrimary = readableTextOn(previewPrimary);
const ratio = contrastRatio(previewPrimary, onPrimary);

const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (hasErrors) return;
setBrandConfig((prev) => ({
...prev,
orgName: sanitizeOrgName(orgName) ?? undefined,
logoUrl: sanitizeLogoUrl(logoUrl) ?? undefined,
primaryColor: normalizeHexColor(primary) ?? undefined,
accentColor: normalizeHexColor(accent) ?? undefined,
}));
setSaved(true);
};

const handleReset = () => {
resetBrandConfig();
setOrgName('');
setLogoUrl('');
setPrimary(DEFAULT_PRIMARY);
setAccent(DEFAULT_ACCENT);
setSaved(false);
};

const inputClass =
'w-full rounded-xl border border-[var(--border-hi)] bg-[var(--surface-hi)] px-3 py-2 text-sm text-[var(--text)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]';

return (
<div className="card glass noise p-6 md:p-8">
<div className="flex items-center gap-3 mb-6">
<div className="rounded-2xl border border-[var(--border)] bg-[var(--surface-hi)] p-2.5">
<Palette className="h-5 w-5 text-[var(--accent)]" aria-hidden="true" />
</div>
<div>
<h2 className="text-lg font-bold text-[var(--text)]">Organization branding</h2>
<p className="text-sm text-[var(--muted)] mt-1">
Set your organization&apos;s logo and colours to theme your portal.
</p>
</div>
</div>

<form onSubmit={handleSave} noValidate className="grid gap-5 md:grid-cols-2">
<div className="space-y-1.5">
<label htmlFor={ids.name} className="text-sm font-semibold text-[var(--text)]">
Organization name
</label>
<input id={ids.name} className={inputClass} value={orgName} maxLength={60}
onChange={(e) => { setOrgName(e.target.value); setSaved(false); }} />
</div>

<div className="space-y-1.5">
<label htmlFor={ids.logo} className="text-sm font-semibold text-[var(--text)]">
Logo URL (https)
</label>
<input id={ids.logo} type="url" inputMode="url" className={inputClass} value={logoUrl}
placeholder="https://cdn.example.com/logo.png"
aria-invalid={logoInvalid} aria-describedby={logoInvalid ? ids.err : undefined}
onChange={(e) => { setLogoUrl(e.target.value); setSaved(false); }} />
{logoInvalid && (
<p id={ids.err} role="alert" className="text-xs text-[var(--danger,#dc2626)]">
Use an https:// image URL (PNG, JPG, GIF or WebP).
</p>
)}
</div>

{([
['Primary colour', ids.primary, primary, setPrimary, primaryInvalid],
['Accent colour', ids.accent, accent, setAccent, accentInvalid],
] as const).map(([label, id, value, set, invalid]) => (
<div key={id} className="space-y-1.5">
<label htmlFor={id} className="text-sm font-semibold text-[var(--text)]">{label}</label>
<div className="flex items-center gap-2">
<input type="color" aria-label={`${label} picker`}
className="h-10 w-12 shrink-0 cursor-pointer rounded-lg border border-[var(--border-hi)] bg-transparent"
value={normalizeHexColor(value) ?? DEFAULT_PRIMARY}
onChange={(e) => { set(e.target.value); setSaved(false); }} />
<input id={id} className={inputClass} value={value} aria-invalid={invalid}
onChange={(e) => { set(e.target.value); setSaved(false); }} />
</div>
{invalid && (
<p role="alert" className="text-xs text-[var(--danger,#dc2626)]">
Enter a hex colour like #0a2540.
</p>
)}
</div>
))}

<div className="md:col-span-2 rounded-2xl border border-[var(--border-hi)] p-4" aria-label="Branding preview">
<p className="text-xs font-bold uppercase tracking-[0.2em] text-[var(--muted)] mb-3">Preview</p>
<div className="flex flex-wrap items-center gap-3">
<span className="rounded-xl px-4 py-2 text-sm font-bold"
style={{ background: previewPrimary, color: onPrimary }}>
Run payroll
</span>
<span className="text-sm font-semibold" style={{ color: normalizeHexColor(accent) ?? DEFAULT_ACCENT }}>
View details →
</span>
<span className="text-xs text-[var(--muted)]">
Button text contrast {ratio.toFixed(1)}:1 {ratio >= AA_CONTRAST ? '(AA)' : '(below AA)'}
</span>
</div>
</div>

<div className="md:col-span-2 flex flex-wrap items-center gap-3">
<button type="submit" disabled={hasErrors}
className="rounded-xl bg-[var(--accent)] px-5 py-2.5 text-sm font-bold text-[var(--on-accent,#fff)] disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2">
Save branding
</button>
<button type="button" onClick={handleReset}
className="inline-flex items-center gap-2 rounded-xl border border-[var(--border-hi)] px-4 py-2.5 text-sm font-semibold text-[var(--text)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]">
<RotateCcw className="h-4 w-4" aria-hidden="true" />
Reset to PayD default
</button>
<span role="status" aria-live="polite" className="text-sm text-[var(--muted)]">
{saved ? 'Branding saved.' : ''}
</span>
</div>
</form>
</div>
);
};

export default BrandingSettings;
59 changes: 59 additions & 0 deletions frontend/src/components/__tests__/BrandingSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, test } from 'vitest';
import { ThemeProvider } from '../../providers/ThemeProvider';
import { BrandingSettings } from '../BrandingSettings';
import { BrandLogo } from '../BrandLogo';

function renderAll() {
return render(
<ThemeProvider>
<BrandLogo />
<BrandingSettings />
</ThemeProvider>
);
}

describe('BrandingSettings (#1505)', () => {
beforeEach(() => localStorage.removeItem('payd-org-brand'));

test('saves org name, logo and colours and themes the header', async () => {
const user = userEvent.setup();
renderAll();

await user.type(screen.getByLabelText('Organization name'), 'Acme Corp');
await user.type(screen.getByLabelText('Logo URL (https)'), 'https://cdn.acme.com/logo.png');
const primary = screen.getByLabelText('Primary colour');
await user.clear(primary);
await user.type(primary, '#123456');
await user.click(screen.getByRole('button', { name: 'Save branding' }));

expect(screen.getByRole('status')).toHaveTextContent('Branding saved.');
expect(screen.getByAltText('Acme Corp logo')).toHaveAttribute('src', 'https://cdn.acme.com/logo.png');
expect(document.documentElement.style.getPropertyValue('--brand-primary')).toBe('#123456');
expect(JSON.parse(localStorage.getItem('payd-org-brand') ?? '{}')).toMatchObject({
orgName: 'Acme Corp',
primaryColor: '#123456',
});
});

test('blocks save and explains invalid input', async () => {
const user = userEvent.setup();
renderAll();

await user.type(screen.getByLabelText('Logo URL (https)'), 'javascript:alert(1)');
expect(screen.getByText(/Use an https:\/\/ image URL/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save branding' })).toBeDisabled();
});

test('reset restores the default PayD wordmark', async () => {
const user = userEvent.setup();
localStorage.setItem('payd-org-brand', JSON.stringify({ orgName: 'Acme Corp' }));
renderAll();
expect(screen.getAllByText('Acme Corp').length).toBeGreaterThan(0);

await user.click(screen.getByRole('button', { name: 'Reset to PayD default' }));
expect(screen.queryByText('Acme Corp')).toBeNull();
expect(screen.getByText('Pay')).toBeInTheDocument();
});
});
5 changes: 5 additions & 0 deletions frontend/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useNotification } from '../hooks/useNotification';
import { clearAllOfflineCaches } from '../services/offlineHistoryCache';
import { useOnboardingTourStore } from '../stores/onboardingTourStore';
import ComponentErrorBoundary from '../components/ComponentErrorBoundary';
import { BrandingSettings } from '../components/BrandingSettings';

/**
* Each settings section below is its own component (rather than an inline
Expand Down Expand Up @@ -308,6 +309,10 @@ export default function Settings() {
<AppearanceSettingsSection />
</ComponentErrorBoundary>

<ComponentErrorBoundary componentName="Branding Settings">
<BrandingSettings />
</ComponentErrorBoundary>

<ComponentErrorBoundary componentName="Onboarding Tour Settings">
<OnboardingTourSettingsSection />
</ComponentErrorBoundary>
Expand Down
38 changes: 18 additions & 20 deletions frontend/src/providers/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useLayoutEffect, useState, useCallback } from 'react';
import { Theme, ThemeContext, OrgBrandConfig } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { normalizeHexColor, readableTextOn, sanitizeOrgName } from '../utils/brand';

const STORAGE_KEY = 'payd-theme';
const BRAND_STORAGE_KEY = 'payd-org-brand';
Expand Down Expand Up @@ -38,28 +39,25 @@ function persistTheme(next: Theme) {
localStorage.setItem(STORAGE_KEY, next);
}

function setOrRemove(root: HTMLElement, prop: string, value: string | null) {
if (value) root.style.setProperty(prop, value);
else root.style.removeProperty(prop);
}

function applyBrandTheme(brand: OrgBrandConfig) {
const root = document.documentElement;
if (brand.primaryColor) {
root.style.setProperty('--brand-primary', brand.primaryColor);
} else {
root.style.removeProperty('--brand-primary');
}

if (brand.accentColor) {
root.style.setProperty('--brand-accent', brand.accentColor);
} else {
root.style.removeProperty('--brand-accent');
}

if (brand.headerBg) {
root.style.setProperty('--brand-header-bg', brand.headerBg);
} else {
root.style.removeProperty('--brand-header-bg');
}

if (brand.orgName) {
root.setAttribute('data-org-name', brand.orgName);
// Validate org-supplied values before they reach CSS (#1505): only hex
// colours are applied, anything else falls back to the default tokens.
const primary = normalizeHexColor(brand.primaryColor);
setOrRemove(root, '--brand-primary', primary);
// Text/icons on a solid brand fill keep AA contrast whatever the colour.
setOrRemove(root, '--brand-on-primary', primary ? readableTextOn(primary) : null);
setOrRemove(root, '--brand-accent', normalizeHexColor(brand.accentColor));
setOrRemove(root, '--brand-header-bg', normalizeHexColor(brand.headerBg));

const orgName = sanitizeOrgName(brand.orgName);
if (orgName) {
root.setAttribute('data-org-name', orgName);
} else {
root.removeAttribute('data-org-name');
}
Expand Down
Loading