diff --git a/.changeset/app-branding-groundwork.md b/.changeset/app-branding-groundwork.md new file mode 100644 index 000000000..e14f67530 --- /dev/null +++ b/.changeset/app-branding-groundwork.md @@ -0,0 +1,11 @@ +--- +'@openchoreo/backstage-design-system': minor +--- + +Add app-config-driven branding groundwork: `resolveBrandTokens(base, brand?)` +pure helper (brand primary → derived token slots; identity when no overrides) +and `ChoreoTokensProvider` with a context-aware `useChoreoTokens`. The portal +app gains an `app.branding.*` frontend-visible config schema (name, iconLogo, +fullLogo, theme.light/dark.primaryColor) wired into the theme providers, +sidebar logos, and sign-in card. Default behavior with no branding config is +unchanged. diff --git a/packages/app/package.json b/packages/app/package.json index 49a2f31fb..71883bb1f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -23,7 +23,8 @@ "@backstage/ui": "^0.15.0", "@openchoreo/backstage-portal-app": "workspace:^", "react": "18.3.1", - "react-dom": "18.3.1" + "react-dom": "18.3.1", + "react-router-dom": "6.30.1" }, "devDependencies": { "@axe-core/playwright": "^4.11.3", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 22133b5e3..f67c882cc 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -7,20 +7,12 @@ */ import { createBackend } from '@backstage/backend-defaults'; -import { - portalBackendFeatures, - portalRootHttpRouterServiceFactory, -} from '@openchoreo/backstage-portal-backend'; +import { portalBackendFeatures } from '@openchoreo/backstage-portal-backend'; const backend = createBackend(); -// Root HTTP router with the IDP token header middleware — reads the IDP token -// from headers and makes it available to ALL routes via AsyncLocalStorage, -// which is critical for the permission system to access the user's IDP token -// when making authorization decisions. -backend.add(portalRootHttpRouterServiceFactory); - -// The full portal backend composition: Backstage core plugins, the Jenkins CI +// The full portal backend composition: the root HTTP router with the IDP +// token header middleware, Backstage core plugins, the Jenkins CI // integration, and all OpenChoreo backend plugins, modules, and service // factories. backend.add(portalBackendFeatures); diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index 4736b6b68..6fca502b5 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -8,6 +8,9 @@ export type { EntityKindPalette, } from './theme/tokens'; export { useChoreoTokens } from './theme/useChoreoTokens'; +export { resolveBrandTokens } from './theme/brand'; +export type { BrandPaletteOverrides } from './theme/brand'; +export { ChoreoTokensProvider } from './theme/ChoreoTokensProvider'; export { OpenChoreoIcon } from './icons/OpenChoreoIcon'; export { StatusBadge } from './components/StatusBadge'; export type { StatusType } from './components/StatusBadge'; diff --git a/packages/design-system/src/theme/ChoreoTokensProvider.tsx b/packages/design-system/src/theme/ChoreoTokensProvider.tsx new file mode 100644 index 000000000..5011ad574 --- /dev/null +++ b/packages/design-system/src/theme/ChoreoTokensProvider.tsx @@ -0,0 +1,26 @@ +import { ReactNode, createContext } from 'react'; +import { ThemeTokens } from './tokens'; + +/** + * Carries the ACTIVE (possibly brand-overridden) token set down to components + * that read tokens via `useChoreoTokens`. Without a provider the hook falls + * back to the stock `lightTokens`/`darkTokens` singletons, so wrapping is only + * required when tokens diverge from the defaults (e.g. `app.branding.*`). + */ +export const ChoreoTokensContext = createContext( + undefined, +); + +export function ChoreoTokensProvider({ + tokens, + children, +}: { + tokens: ThemeTokens; + children?: ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/packages/design-system/src/theme/brand.test.ts b/packages/design-system/src/theme/brand.test.ts new file mode 100644 index 000000000..69b6c5baa --- /dev/null +++ b/packages/design-system/src/theme/brand.test.ts @@ -0,0 +1,178 @@ +import { resolveBrandTokens } from './brand'; +import { darkTokens, lightTokens } from './tokens'; + +describe('resolveBrandTokens', () => { + // Several branded paths legitimately warn (validation, contrast) — spy + // globally so tests stay quiet and can assert on calls where relevant. + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('returns the base object by reference when no overrides are given', () => { + // This identity IS the pixel-identical default: callers reuse the + // prebuilt theme singletons when `===` holds. + expect(resolveBrandTokens(lightTokens)).toBe(lightTokens); + expect(resolveBrandTokens(lightTokens, {})).toBe(lightTokens); + expect(resolveBrandTokens(darkTokens, { primary: undefined })).toBe( + darkTokens, + ); + }); + + it('ignores unparseable colors instead of throwing (render safety)', () => { + // resolveBrandTokens runs inside the theme provider's render with no + // error boundary above it — a config typo must never crash the app. + // Named color, #-less hex: decomposeColor rejects these. + expect(resolveBrandTokens(lightTokens, { primary: { main: 'teal' } })).toBe( + lightTokens, + ); + expect( + resolveBrandTokens(lightTokens, { primary: { main: '0d9488' } }), + ).toBe(lightTokens); + // CSS4 space-separated syntax: mis-parses to NaN channels. + expect( + resolveBrandTokens(darkTokens, { + primary: { main: 'rgb(13 148 136)' }, + }), + ).toBe(darkTokens); + // Valid main but invalid explicit light: whole override ignored. + expect( + resolveBrandTokens(lightTokens, { + primary: { main: '#0d9488', light: 'bogus' }, + }), + ).toBe(lightTokens); + expect(warn).toHaveBeenCalledTimes(4); + }); + + it('rejects translucent colors (MUI contrast math ignores alpha)', () => { + // A translucent accent would composite against arbitrary surfaces at + // render time while getContrastRatio treats it as opaque — reject it + // like an unparseable value rather than warn on a fictional ratio. + expect( + resolveBrandTokens(lightTokens, { + primary: { main: 'rgba(13, 148, 136, 0.4)' }, + }), + ).toBe(lightTokens); + expect( + resolveBrandTokens(darkTokens, { + primary: { main: 'hsla(174, 84%, 32%, 0.5)' }, + }), + ).toBe(darkTokens); + expect(warn).toHaveBeenCalledTimes(2); + expect(warn.mock.calls[0][0]).toContain('opaque'); + }); + + it('warns when the accent misses WCAG AA contrast but still applies it', () => { + // #0d9488 is ~3.7:1 on white — a popular teal that fails AA for text — + // and its lightened header stop also misses 3:1 under white text. The + // override is honored (the operator owns the palette); the warnings are + // the accessibility signal. + const resolved = resolveBrandTokens(lightTokens, { + primary: { main: '#0d9488' }, + }); + expect(resolved.primary.main).toBe('#0d9488'); + expect(warn).toHaveBeenCalledTimes(2); + expect(warn.mock.calls[0][0]).toContain('4.5:1'); + expect(warn.mock.calls[1][0]).toContain('header gradient'); + }); + + it('warns when only the header gradient stop misses AA large-text contrast', () => { + // #767676 passes 4.5:1 as solid text on white, but lighten(main, 0.25) — + // the light-mode header's fade end under white header text — is ~2.9:1. + const resolved = resolveBrandTokens(lightTokens, { + primary: { main: '#767676' }, + }); + expect(resolved.primary.main).toBe('#767676'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('header gradient'); + }); + + it('does not warn for AA-compliant accents in either mode', () => { + resolveBrandTokens(lightTokens, { primary: { main: '#0f766e' } }); + // Opaque rgb()/rgba() forms are accepted, including explicit alpha 1. + resolveBrandTokens(lightTokens, { primary: { main: 'rgb(15, 118, 110)' } }); + resolveBrandTokens(lightTokens, { + primary: { main: 'rgba(15, 118, 110, 1)' }, + }); + resolveBrandTokens(darkTokens, { primary: { main: '#2dd4bf' } }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('derives the primary scale from main when light/dark are omitted', () => { + const resolved = resolveBrandTokens(lightTokens, { + primary: { main: '#0d9488' }, + }); + expect(resolved.primary.main).toBe('#0d9488'); + expect(resolved.primary.light).not.toBe(lightTokens.primary.light); + expect(resolved.primary.dark).not.toBe(lightTokens.primary.dark); + // MUI lighten/darken emit rgb() strings. + expect(resolved.primary.light).toMatch(/^rgb/); + expect(resolved.primary.dark).toMatch(/^rgb/); + }); + + it('respects explicit light/dark overrides', () => { + const resolved = resolveBrandTokens(lightTokens, { + primary: { main: '#0d9488', light: '#ccfbf1', dark: '#0f766e' }, + }); + expect(resolved.primary).toEqual({ + main: '#0d9488', + light: '#ccfbf1', + dark: '#0f766e', + }); + }); + + it('recomputes the brand-accent slots in light mode', () => { + const resolved = resolveBrandTokens(lightTokens, { + primary: { main: '#0d9488', dark: '#0f766e' }, + }); + expect(resolved.text.link).toBe('#0d9488'); + expect(resolved.text.linkHover).toBe('#0f766e'); + expect(resolved.navigation.indicator).toBe('#0d9488'); + expect(resolved.navigation.selectedColor).toBe('#0d9488'); + expect(resolved.status.info).toBe('#0d9488'); + expect(resolved.status.running).toBe('#0d9488'); + expect(resolved.banner.info).toBe('#0d9488'); + expect(resolved.banner.link).toBe('#0d9488'); + expect(resolved.graph.edge).toBe('#0d9488'); + expect(resolved.graph.minimapViewportTint).toContain('0.1'); + expect(resolved.gradient.header).toContain('#0d9488'); + expect(resolved.gradient.cardHeader).toContain('#0d9488'); + expect(resolved.gradient.burst).toContain('#0d9488'); + expect(resolved.bursts.gradient).toContain('#0d9488'); + }); + + it('maps light-role slots to the light scale in dark mode', () => { + const resolved = resolveBrandTokens(darkTokens, { + primary: { main: '#2dd4bf', light: '#99f6e4' }, + }); + expect(resolved.text.link).toBe('#2dd4bf'); + expect(resolved.text.linkHover).toBe('#99f6e4'); + expect(resolved.navigation.selectedColor).toBe('#99f6e4'); + expect(resolved.banner.link).toBe('#99f6e4'); + }); + + it('keeps the navy gradients in dark mode', () => { + const resolved = resolveBrandTokens(darkTokens, { + primary: { main: '#2dd4bf' }, + }); + expect(resolved.gradient).toBe(darkTokens.gradient); + expect(resolved.bursts).toBe(darkTokens.bursts); + }); + + it('does not touch non-brand slots or mutate the base', () => { + const before = JSON.stringify(lightTokens); + const resolved = resolveBrandTokens(lightTokens, { + primary: { main: '#0d9488' }, + }); + expect(resolved.secondary).toBe(lightTokens.secondary); + expect(resolved.indigo).toBe(lightTokens.indigo); + expect(resolved.entityKind).toBe(lightTokens.entityKind); + expect(resolved.statusBackground).toBe(lightTokens.statusBackground); + expect(JSON.stringify(lightTokens)).toBe(before); + }); +}); diff --git a/packages/design-system/src/theme/brand.ts b/packages/design-system/src/theme/brand.ts new file mode 100644 index 000000000..26edc5f09 --- /dev/null +++ b/packages/design-system/src/theme/brand.ts @@ -0,0 +1,190 @@ +import { + alpha, + darken, + decomposeColor, + getContrastRatio, + lighten, +} from '@material-ui/core/styles'; +import { ColorScale, ThemeTokens } from './tokens'; + +/** + * Throws unless `color` is an opaque format MUI's color utilities fully + * parse. `decomposeColor` rejects named colors and `#`-less hex outright, + * but silently mis-parses CSS4 space-separated syntax (`rgb(13 148 136)` → + * one value) — hence the component-count check. + */ +function assertParseableColor(color: string) { + const { type, values } = decomposeColor(color); + if (values.length < 3 || values.some(v => Number.isNaN(v))) { + throw new Error( + `Unsupported \`${color}\` color: \`${type}\` needs comma-separated components`, + ); + } + // MUI's luminance/lighten math ignores alpha, so a translucent brand color + // would composite against arbitrary surfaces at render time while the + // contrast check below silently treats it as opaque. + if (values.length === 4 && values[3] < 1) { + throw new Error(`Unsupported \`${color}\` color: must be fully opaque`); + } +} + +/** + * Brand overrides that can be applied on top of a base token set. + * + * Only the brand accent is overridable for now; the rest of the palette + * (neutral greys, status colors, typography) is deliberately fixed so a + * one-line rebrand cannot degrade readability. `light`/`dark` are derived + * from `main` when omitted. + */ +export interface BrandPaletteOverrides { + primary?: { + main: string; + light?: string; + dark?: string; + }; +} + +/** + * Applies {@link BrandPaletteOverrides} to a base {@link ThemeTokens} set, + * recomputing every token slot that carries the brand accent today. + * + * Identity guarantee: when `brand` provides no `primary.main`, the SAME + * object reference as `base` is returned — callers can rely on `===` to keep + * using prebuilt theme singletons, making the no-branding path byte-identical + * to the stock look. + * + * Recomputed from `primary.main`: the primary scale, `text.link`/`linkHover`, + * `navigation.indicator`/`selectedColor`, `status.info`/`running`, + * `banner.info`/`link`, `graph.edge` + minimap viewport tints, and — in light + * mode only — the `gradient.*` and `bursts.gradient` brand gradients. + * + * NOT recomputed (kept from `base`): dark-mode gradients (built on the navy + * `indigo` ramp; deriving a dark ramp from an arbitrary hue is guesswork), + * the `indigo` ramp itself, `entityKind` accents, `statusBackground.info` + * tint, and `secondary` (it is the neutral text ramp, not a brand accent). + * + * Accessibility: the accent is applied verbatim, never auto-adjusted, but + * two checks warn (never gate — the operator owns the final palette): `main` + * below WCAG AA 4.5:1 against the mode's page background, and — light mode + * only — a header gradient stop below 3:1 (AA large text) under white header + * text. Translucent colors (alpha < 1) are rejected like unparseable ones: + * MUI's contrast math ignores alpha, so their ratios would be fiction. + */ +export function resolveBrandTokens( + base: ThemeTokens, + brand?: BrandPaletteOverrides, +): ThemeTokens { + const main = brand?.primary?.main; + if (!main) { + return base; + } + + try { + assertParseableColor(main); + if (brand.primary?.light) assertParseableColor(brand.primary.light); + if (brand.primary?.dark) assertParseableColor(brand.primary.dark); + } catch (error) { + // A branding typo must degrade to the stock look, never crash the app: + // this runs inside the theme provider's render, above any error boundary. + // eslint-disable-next-line no-console + console.warn(`Ignoring brand primary color override: ${error}`); + return base; + } + + const isDark = base.mode === 'dark'; + + // The accent renders as link text on the page background and as the fill + // under white header/button text — the same ratio governs both directions. + const contrast = getContrastRatio(main, base.surface.default); + if (contrast < 4.5) { + // eslint-disable-next-line no-console + console.warn( + `Brand primary color "${main}" has ${contrast.toFixed(2)}:1 contrast ` + + `against the ${base.mode} background (WCAG AA text needs 4.5:1); ` + + `links and header text may be hard to read — consider a ` + + `${isDark ? 'lighter' : 'darker'} shade.`, + ); + } + + // Light mode paints white header text over a gradient fading toward + // `lighten(main, 0.25)`; the fade end is the worst case, so check it at + // WCAG AA for large text (3:1 — header titles render large; the stock + // ramp's own stop sits at 3.16:1, which calibrates the threshold). + if (!isDark) { + const headerContrast = getContrastRatio('#ffffff', lighten(main, 0.25)); + if (headerContrast < 3) { + // eslint-disable-next-line no-console + console.warn( + `Brand primary color "${main}" fades to a header gradient stop with ` + + `${headerContrast.toFixed(2)}:1 contrast under white header text ` + + `(WCAG AA large text needs 3:1) — consider a darker shade.`, + ); + } + } + + // Light mode's `primary.light` is a near-white tint (chip/selection + // backgrounds); dark mode's is only slightly lighter than `main` (link + // hover, selected labels) — hence the mode-dependent coefficient. + const primary: ColorScale = { + main, + light: brand.primary?.light ?? lighten(main, isDark ? 0.25 : 0.9), + dark: brand.primary?.dark ?? darken(main, 0.15), + }; + + return { + ...base, + primary, + text: { + ...base.text, + link: main, + linkHover: isDark ? primary.light : primary.dark, + }, + status: { + ...base.status, + info: main, + running: main, + }, + navigation: { + ...base.navigation, + indicator: main, + selectedColor: isDark ? primary.light : main, + }, + banner: { + ...base.banner, + info: main, + link: isDark ? primary.light : main, + }, + graph: { + ...base.graph, + edge: main, + minimapViewportTint: alpha(main, 0.1), + minimapViewportTintActive: alpha(main, isDark ? 0.25 : 0.22), + minimapViewportBorder: alpha(main, isDark ? 0.5 : 0.45), + }, + ...(isDark + ? {} + : { + gradient: { + header: `linear-gradient(90deg, ${main} 0%, ${lighten( + main, + 0.25, + )} 100%)`, + cardHeader: `linear-gradient(135deg, ${main} 0%, ${lighten( + main, + 0.25, + )} 100%)`, + burst: `linear-gradient(135deg, ${main} 0%, ${lighten( + main, + 0.5, + )} 100%)`, + }, + bursts: { + ...base.bursts, + gradient: `linear-gradient(135deg, ${main} 0%, ${lighten( + main, + 0.5, + )} 100%)`, + }, + }), + }; +} diff --git a/packages/design-system/src/theme/useChoreoTokens.test.tsx b/packages/design-system/src/theme/useChoreoTokens.test.tsx new file mode 100644 index 000000000..e3110659f --- /dev/null +++ b/packages/design-system/src/theme/useChoreoTokens.test.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from 'react'; +import { renderHook } from '@testing-library/react'; +import { ThemeProvider, createTheme } from '@material-ui/core/styles'; +import { ChoreoTokensProvider } from './ChoreoTokensProvider'; +import { darkTokens, lightTokens } from './tokens'; +import { useChoreoTokens } from './useChoreoTokens'; + +function muiWrapper(type: 'light' | 'dark') { + const theme = createTheme({ palette: { type } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe('useChoreoTokens', () => { + it('falls back to the singletons picked by palette type', () => { + const light = renderHook(() => useChoreoTokens(), { + wrapper: muiWrapper('light'), + }); + expect(light.result.current).toBe(lightTokens); + + const dark = renderHook(() => useChoreoTokens(), { + wrapper: muiWrapper('dark'), + }); + expect(dark.result.current).toBe(darkTokens); + }); + + it('prefers tokens from ChoreoTokensProvider', () => { + const branded = { ...lightTokens, deletionWarning: '#123456' }; + const Mui = muiWrapper('light'); + const { result } = renderHook(() => useChoreoTokens(), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + expect(result.current).toBe(branded); + }); +}); diff --git a/packages/design-system/src/theme/useChoreoTokens.ts b/packages/design-system/src/theme/useChoreoTokens.ts index f855ceada..0c3dd4b84 100644 --- a/packages/design-system/src/theme/useChoreoTokens.ts +++ b/packages/design-system/src/theme/useChoreoTokens.ts @@ -1,4 +1,6 @@ +import { useContext } from 'react'; import { useTheme } from '@material-ui/core/styles'; +import { ChoreoTokensContext } from './ChoreoTokensProvider'; import { darkTokens, lightTokens, ThemeTokens } from './tokens'; /** @@ -7,8 +9,16 @@ import { darkTokens, lightTokens, ThemeTokens } from './tokens'; * MUI's `theme.palette` object only carries MUI-shaped slots; this hook surfaces * the extra tokens (scrim tiers, entity-kind palette, gradients, editor/graph * colors) that components need to stay fully theme-aware. + * + * Prefers the tokens supplied by a {@link ChoreoTokensProvider} (which carry + * any `app.branding.*` overrides); outside a provider it falls back to the + * stock singletons picked by the MUI palette type. */ export function useChoreoTokens(): ThemeTokens { + const contextTokens = useContext(ChoreoTokensContext); const theme = useTheme(); + if (contextTokens) { + return contextTokens; + } return theme.palette.type === 'dark' ? darkTokens : lightTokens; } diff --git a/packages/portal-app/config.d.ts b/packages/portal-app/config.d.ts new file mode 100644 index 000000000..7fd97f592 --- /dev/null +++ b/packages/portal-app/config.d.ts @@ -0,0 +1,66 @@ +export interface Config { + app?: { + /** + * Portal branding overrides — re-brand the OpenChoreo portal via config, + * without a fork or rebuild. All values are optional; omitting the whole + * block yields the stock OpenChoreo look. Unrelated to `app.title` + * (browser/window title) and `organization.name` (Backstage org + * components) — there is deliberately no fallback chaining between them. + * @deepVisibility frontend + */ + branding?: { + /** + * Product name shown as the sidebar wordmark and on the sign-in card. + * Defaults to "OpenChoreo". + * @visibility frontend + */ + name?: string; + /** + * Square logo for the collapsed sidebar. Absolute URL or data URI, used + * verbatim as an img src (rendered at 24px height). Defaults to the + * OpenChoreo mark. Data URIs are recommended: remote URLs may require + * extending `backend.csp` img-src in production. + * @visibility frontend + */ + iconLogo?: string; + /** + * Logo for the expanded sidebar. Absolute URL or data URI, used + * verbatim as an img src (max 32px tall / 180px wide). When set it + * replaces the icon + wordmark row entirely. Data URIs recommended, + * same CSP caveat as `iconLogo`. + * @visibility frontend + */ + fullLogo?: string; + theme?: { + light?: { + /** + * Brand accent for the light theme. Supported formats: hex with a + * leading "#" (e.g. "#0d9488"), or fully-opaque + * rgb()/rgba()/hsl()/hsla() with comma-separated components. Named + * colors ("teal"), space-separated CSS4 syntax, and translucent + * values (alpha < 1) are not supported — invalid values are + * ignored with a console warning and the stock palette kept. + * Drives the primary palette, links, sidebar selection, header + * gradients, and graph accents. Parts of it render as text, so + * prefer >= 4.5:1 contrast against white (WCAG AA); the header + * also fades it toward white under white text (>= 3:1 needed for + * the lightened stop). Lower-contrast colors log a console + * warning. + * @visibility frontend + */ + primaryColor?: string; + }; + dark?: { + /** + * Brand accent for the dark theme. Same supported formats and + * validation as the light accent; prefer >= 4.5:1 contrast against + * the dark page background (#0f1117). Dark header gradients keep + * the default navy ramp in this version. + * @visibility frontend + */ + primaryColor?: string; + }; + }; + }; + }; +} diff --git a/packages/portal-app/package.json b/packages/portal-app/package.json index 1551bd185..ca81a869b 100644 --- a/packages/portal-app/package.json +++ b/packages/portal-app/package.json @@ -20,6 +20,7 @@ "backstage": { "role": "web-library" }, + "configSchema": "config.d.ts", "scripts": { "start": "backstage-cli package start", "build": "backstage-cli package build", @@ -103,6 +104,7 @@ "@backstage/cli": "^0.36.2", "@backstage/frontend-test-utils": "^0.6.0", "@backstage/test-utils": "^1.7.18", + "@backstage/types": "^1.2.2", "@testing-library/dom": "9.3.4", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "14.3.1", @@ -113,6 +115,7 @@ "react-router-dom": "6.30.1" }, "files": [ - "dist" + "dist", + "config.d.ts" ] } diff --git a/packages/portal-app/src/branding.test.ts b/packages/portal-app/src/branding.test.ts new file mode 100644 index 000000000..e16354e8a --- /dev/null +++ b/packages/portal-app/src/branding.test.ts @@ -0,0 +1,108 @@ +import { mockApis } from '@backstage/test-utils'; +import { + DEFAULT_BRAND_NAME, + brandName, + readBrandingConfig, + toBrandOverrides, +} from './branding'; + +describe('readBrandingConfig', () => { + it('parses a full branding block', () => { + const config = mockApis.config({ + data: { + app: { + branding: { + name: 'Acme Portal', + iconLogo: 'data:image/svg+xml;base64,abc', + fullLogo: 'https://example.com/logo.svg', + theme: { + light: { primaryColor: '#0d9488' }, + dark: { primaryColor: '#2dd4bf' }, + }, + }, + }, + }, + }); + expect(readBrandingConfig(config)).toEqual({ + name: 'Acme Portal', + iconLogo: 'data:image/svg+xml;base64,abc', + fullLogo: 'https://example.com/logo.svg', + theme: { + light: { primaryColor: '#0d9488' }, + dark: { primaryColor: '#2dd4bf' }, + }, + }); + }); + + it('returns an empty shape when app.branding is absent', () => { + const branding = readBrandingConfig(mockApis.config({ data: {} })); + expect(branding.name).toBeUndefined(); + expect(branding.iconLogo).toBeUndefined(); + expect(branding.fullLogo).toBeUndefined(); + expect(branding.theme).toBeUndefined(); + }); + + it('treats malformed values as unset instead of throwing (render safety)', () => { + // ConfigReader throws for present-but-invalid values — including empty + // strings, which pass schema validation. readBrandingConfig runs during + // render, so it must swallow these, not crash the app. + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const branding = readBrandingConfig( + mockApis.config({ + data: { + app: { + branding: { + name: '', + iconLogo: 123 as any, + fullLogo: 'https://example.com/logo.svg', + theme: { light: { primaryColor: '' } }, + }, + }, + }, + }), + ); + expect(branding.name).toBeUndefined(); + expect(branding.iconLogo).toBeUndefined(); + expect(branding.fullLogo).toBe('https://example.com/logo.svg'); + expect(branding.theme).toBeUndefined(); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('brandName', () => { + it('falls back to OpenChoreo', () => { + expect(brandName({})).toBe(DEFAULT_BRAND_NAME); + expect(brandName({ name: 'Acme' })).toBe('Acme'); + }); +}); + +describe('toBrandOverrides', () => { + it('returns undefined when no primary color is set for the mode', () => { + expect(toBrandOverrides({}, 'light')).toBeUndefined(); + expect( + toBrandOverrides( + { theme: { dark: { primaryColor: '#2dd4bf' } } }, + 'light', + ), + ).toBeUndefined(); + }); + + it('maps the per-mode primary color', () => { + const branding = { + theme: { + light: { primaryColor: '#0d9488' }, + dark: { primaryColor: '#2dd4bf' }, + }, + }; + expect(toBrandOverrides(branding, 'light')).toEqual({ + primary: { main: '#0d9488' }, + }); + expect(toBrandOverrides(branding, 'dark')).toEqual({ + primary: { main: '#2dd4bf' }, + }); + }); +}); diff --git a/packages/portal-app/src/branding.ts b/packages/portal-app/src/branding.ts new file mode 100644 index 000000000..19ba71aca --- /dev/null +++ b/packages/portal-app/src/branding.ts @@ -0,0 +1,96 @@ +import { useMemo } from 'react'; +import { ConfigApi, configApiRef, useApi } from '@backstage/core-plugin-api'; +import { BrandPaletteOverrides } from '@openchoreo/backstage-design-system'; + +/** Product name used when `app.branding.name` is not configured. */ +export const DEFAULT_BRAND_NAME = 'OpenChoreo'; + +/** + * The portal's `app.branding.*` configuration (see `config.d.ts`). Every + * field is optional; an empty object means the stock OpenChoreo look. + * + * @public + */ +export interface BrandingConfig { + name?: string; + iconLogo?: string; + fullLogo?: string; + theme?: { + light?: { primaryColor?: string }; + dark?: { primaryColor?: string }; + }; +} + +/** + * Reads one branding string, treating malformed values as unset. ConfigReader + * THROWS (not `undefined`) for present-but-invalid values — notably empty + * strings, which pass schema validation — and `readBrandingConfig` runs + * during render, so an uncaught throw would white-screen the app. + */ +function readOptionalString( + config: ConfigApi, + key: string, +): string | undefined { + try { + return config.getOptionalString(key); + } catch (error) { + // eslint-disable-next-line no-console + console.warn(`Ignoring invalid branding config: ${error}`); + return undefined; + } +} + +/** Reads `app.branding.*` from config. Exported for tests and non-hook use. */ +export function readBrandingConfig(config: ConfigApi): BrandingConfig { + const primary = (mode: 'light' | 'dark') => + readOptionalString(config, `app.branding.theme.${mode}.primaryColor`); + const light = primary('light'); + const dark = primary('dark'); + return { + name: readOptionalString(config, 'app.branding.name'), + iconLogo: readOptionalString(config, 'app.branding.iconLogo'), + fullLogo: readOptionalString(config, 'app.branding.fullLogo'), + ...(light || dark + ? { + theme: { + ...(light ? { light: { primaryColor: light } } : {}), + ...(dark ? { dark: { primaryColor: dark } } : {}), + }, + } + : {}), + }; +} + +/** + * Returns the active {@link BrandingConfig}. The result is memoized on the + * config API instance so consumers can safely use it in hook dependencies — + * theme rebuilds key off its identity. + * + * @public + */ +export function useBranding(): BrandingConfig { + const configApi = useApi(configApiRef); + return useMemo(() => readBrandingConfig(configApi), [configApi]); +} + +/** + * The configured product name, falling back to "OpenChoreo". + * + * @public + */ +export function brandName(branding: BrandingConfig): string { + return branding.name ?? DEFAULT_BRAND_NAME; +} + +/** + * Converts branding config into design-system brand overrides for one theme + * mode. Returns `undefined` when no brand color is configured for that mode — + * the signal for callers to keep the prebuilt stock theme. + */ +export function toBrandOverrides( + branding: BrandingConfig, + mode: 'light' | 'dark', +): BrandPaletteOverrides | undefined { + const main = branding.theme?.[mode]?.primaryColor; + return main ? { primary: { main } } : undefined; +} diff --git a/packages/portal-app/src/components/DynamicSignInPage.tsx b/packages/portal-app/src/components/DynamicSignInPage.tsx index 34984e443..08eb42800 100644 --- a/packages/portal-app/src/components/DynamicSignInPage.tsx +++ b/packages/portal-app/src/components/DynamicSignInPage.tsx @@ -2,6 +2,7 @@ import { SignInPage } from '@backstage/core-components'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; import type { SignInPageProps } from '@backstage/plugin-app-react'; import { openChoreoAuthApiRef } from '../apis/authRefs'; +import { brandName, useBranding } from '../branding'; /** * Dynamic SignInPage that switches between OpenChoreo OIDC and guest mode @@ -18,6 +19,7 @@ import { openChoreoAuthApiRef } from '../apis/authRefs'; */ export function DynamicSignInPage(props: SignInPageProps) { const configApi = useApi(configApiRef); + const branding = useBranding(); const authEnabled = configApi.getOptionalBoolean('openchoreo.features.auth.enabled') ?? true; @@ -25,13 +27,14 @@ export function DynamicSignInPage(props: SignInPageProps) { return ; } + const name = brandName(branding); return ( diff --git a/packages/portal-app/src/components/Root/LogoFull.test.tsx b/packages/portal-app/src/components/Root/LogoFull.test.tsx new file mode 100644 index 000000000..81d6b1cb0 --- /dev/null +++ b/packages/portal-app/src/components/Root/LogoFull.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { configApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider, mockApis } from '@backstage/test-utils'; +import type { JsonObject } from '@backstage/types'; +import LogoFull from './LogoFull'; +import LogoIcon from './LogoIcon'; + +function renderWithBranding(element: JSX.Element, branding?: JsonObject) { + return render( + + {element} + , + ); +} + +describe('LogoFull', () => { + it('renders the OpenChoreo mark and wordmark by default', () => { + const { container } = renderWithBranding(); + expect(screen.getByText('OpenChoreo')).toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders a custom wordmark from app.branding.name', () => { + renderWithBranding(, { name: 'Acme Portal' }); + expect(screen.getByText('Acme Portal')).toBeInTheDocument(); + expect(screen.queryByText('OpenChoreo')).not.toBeInTheDocument(); + }); + + it('renders the configured fullLogo image instead of icon + wordmark', () => { + const src = 'data:image/svg+xml;base64,abc'; + const { container } = renderWithBranding(, { + name: 'Acme Portal', + fullLogo: src, + }); + expect(screen.getByRole('img', { name: 'Acme Portal' })).toHaveAttribute( + 'src', + src, + ); + expect(container.querySelector('svg')).not.toBeInTheDocument(); + expect(screen.queryByText('Acme Portal')).not.toBeInTheDocument(); + }); +}); + +describe('LogoIcon', () => { + it('renders the OpenChoreo mark by default', () => { + const { container } = renderWithBranding(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders the configured iconLogo image', () => { + const src = 'data:image/svg+xml;base64,abc'; + const { container } = renderWithBranding(, { iconLogo: src }); + expect(container.querySelector('img')).toHaveAttribute('src', src); + expect(container.querySelector('svg')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/portal-app/src/components/Root/LogoFull.tsx b/packages/portal-app/src/components/Root/LogoFull.tsx index b4f271b78..94f229b7c 100644 --- a/packages/portal-app/src/components/Root/LogoFull.tsx +++ b/packages/portal-app/src/components/Root/LogoFull.tsx @@ -1,6 +1,7 @@ import { Box, makeStyles } from '@material-ui/core'; import { Typography } from '@material-ui/core'; import { OpenChoreoIcon } from '@openchoreo/backstage-design-system'; +import { brandName, useBranding } from '../../branding'; const useStyles = makeStyles(theme => ({ logoText: { @@ -10,12 +11,23 @@ const useStyles = makeStyles(theme => ({ const LogoFull = () => { const classes = useStyles(); + const branding = useBranding(); + + if (branding.fullLogo) { + return ( + {brandName(branding)} + ); + } return ( - OpenChoreo + {brandName(branding)} ); diff --git a/packages/portal-app/src/components/Root/LogoIcon.tsx b/packages/portal-app/src/components/Root/LogoIcon.tsx index 8391b0b7a..2616dc210 100644 --- a/packages/portal-app/src/components/Root/LogoIcon.tsx +++ b/packages/portal-app/src/components/Root/LogoIcon.tsx @@ -1,6 +1,19 @@ import { OpenChoreoIcon } from '@openchoreo/backstage-design-system'; +import { useBranding } from '../../branding'; const LogoIcon = () => { + const branding = useBranding(); + + if (branding.iconLogo) { + return ( + + ); + } + return ; }; diff --git a/packages/portal-app/src/index.ts b/packages/portal-app/src/index.ts index a8b13f506..a981ba932 100644 --- a/packages/portal-app/src/index.ts +++ b/packages/portal-app/src/index.ts @@ -16,3 +16,5 @@ export { createPortalApp } from './createPortalApp'; export type { PortalAppOptions } from './createPortalApp'; +export { brandName, useBranding, DEFAULT_BRAND_NAME } from './branding'; +export type { BrandingConfig } from './branding'; diff --git a/packages/portal-app/src/themes.test.tsx b/packages/portal-app/src/themes.test.tsx new file mode 100644 index 000000000..f3588b953 --- /dev/null +++ b/packages/portal-app/src/themes.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react'; +import { useTheme } from '@material-ui/core/styles'; +import { configApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider, mockApis } from '@backstage/test-utils'; +import { useChoreoTokens } from '@openchoreo/backstage-design-system'; +import { appThemes } from './themes'; + +// Probes the two token paths a brand color must reach: the MUI palette +// (via UnifiedThemeProvider) and the extended tokens (via useChoreoTokens). +function Probe() { + const theme = useTheme(); + const tokens = useChoreoTokens(); + return ( + <> + {theme.palette.primary.main} + {tokens.graph.edge} + + ); +} + +function renderThemeProvider( + themeId: 'openchoreo-light' | 'openchoreo-dark', + config: ReturnType, +) { + const entry = appThemes.find(t => t.id === themeId)!; + const Provider = entry.Provider; + return render( + + + + + , + ); +} + +describe('appThemes', () => { + it('applies the configured brand primary to palette and tokens', () => { + // This also proves the config API is readable inside a theme Provider — + // the load-bearing assumption of config-driven branding. + renderThemeProvider( + 'openchoreo-light', + mockApis.config({ + data: { + app: { + branding: { theme: { light: { primaryColor: '#b91c1c' } } }, + }, + }, + }), + ); + expect(screen.getByTestId('palette-primary').textContent).toBe('#b91c1c'); + expect(screen.getByTestId('graph-edge').textContent).toBe('#b91c1c'); + }); + + it('applies the dark brand primary when the dark theme renders', () => { + // Distinct per-mode colors prove mode selection, not just plumbing. + renderThemeProvider( + 'openchoreo-dark', + mockApis.config({ + data: { + app: { + branding: { + theme: { + light: { primaryColor: '#b91c1c' }, + dark: { primaryColor: '#2dd4bf' }, + }, + }, + }, + }, + }), + ); + expect(screen.getByTestId('palette-primary').textContent).toBe('#2dd4bf'); + expect(screen.getByTestId('graph-edge').textContent).toBe('#2dd4bf'); + }); + + it('serves the stock theme when no branding is configured', () => { + renderThemeProvider('openchoreo-light', mockApis.config({ data: {} })); + expect(screen.getByTestId('palette-primary').textContent).toBe('#5568c4'); + expect(screen.getByTestId('graph-edge').textContent).toBe('#5568c4'); + }); +}); diff --git a/packages/portal-app/src/themes.tsx b/packages/portal-app/src/themes.tsx index 5654ee21b..8b66c4ec9 100644 --- a/packages/portal-app/src/themes.tsx +++ b/packages/portal-app/src/themes.tsx @@ -1,14 +1,18 @@ -import { useEffect, type ReactNode } from 'react'; +import { useEffect, useMemo, type ReactNode } from 'react'; import type { AppTheme } from '@backstage/core-plugin-api'; import { UnifiedThemeProvider } from '@backstage/theme'; import { + buildOpenChoreoTheme, + ChoreoTokensProvider, darkTokens, lightTokens, OpenChoreoIcon, openChoreoDarkTheme, openChoreoTheme, + resolveBrandTokens, type ThemeTokens, } from '@openchoreo/backstage-design-system'; +import { toBrandOverrides, useBranding } from './branding'; // Backstage v1.51's `UnifiedThemeProvider` already sets `data-theme-mode` on // `` so `@backstage/ui` (BUI) flips to its dark variant — but BUI's @@ -26,16 +30,19 @@ import { // previous pre-migration look (page surround = `#f8f8f8`, cards = white). // Forcing our `surface.default` (`#ffffff`) here would flatten the page // against the cards. +// +// Takes the ACTIVE token set (not a mode) so `app.branding.*` primary-color +// overrides reach BUI's solid-button variables too. function BuiThemeBridge({ - mode, + tokens, children, }: { - mode: 'light' | 'dark'; + tokens: ThemeTokens; children?: ReactNode; }) { useEffect(() => { const body = document.body; - const t: ThemeTokens = mode === 'dark' ? darkTokens : lightTokens; + const t = tokens; // Align BUI primary buttons (e.g. Scaffolder Review) with our MUI // `containedPrimary` fill (used by Scaffolder Create). Applied in both // modes so the two button systems read as one. @@ -49,7 +56,7 @@ function BuiThemeBridge({ // (`#f8f8f8` page surround etc.) already match the pre-migration look, // and forcing `surface.default` (`#ffffff`) in light flattens the page. const surfaceOverrides: Record = - mode === 'dark' + t.mode === 'dark' ? { '--bui-bg-app': t.surface.default, '--bui-bg-surface-1': t.surface.paper, @@ -70,10 +77,47 @@ function BuiThemeBridge({ else body.style.removeProperty(name); } }; - }, [mode]); + }, [tokens]); return <>{children}; } +/** + * Theme provider honoring `app.branding.*` (see `config.d.ts`): when a brand + * primary color is configured for the active mode, the theme is rebuilt from + * brand-resolved tokens; otherwise the prebuilt stock theme singletons are + * reused, keeping the default render byte-identical to the unbranded portal. + * The resolved tokens also flow to `useChoreoTokens` consumers (via + * `ChoreoTokensProvider`) and to the BUI CSS-variable bridge. + */ +function BrandedThemeProvider({ + mode, + children, +}: { + mode: 'light' | 'dark'; + children?: ReactNode; +}) { + const branding = useBranding(); + const { theme, tokens } = useMemo(() => { + const base = mode === 'dark' ? darkTokens : lightTokens; + const resolved = resolveBrandTokens(base, toBrandOverrides(branding, mode)); + if (resolved === base) { + return { + theme: mode === 'dark' ? openChoreoDarkTheme : openChoreoTheme, + tokens: base, + }; + } + return { theme: buildOpenChoreoTheme(resolved), tokens: resolved }; + }, [mode, branding]); + + return ( + + + {children} + + + ); +} + /** * App themes registered with `createApp`. * @@ -91,9 +135,7 @@ export const appThemes: AppTheme[] = [ variant: 'dark', icon: , Provider: ({ children }) => ( - - {children} - + {children} ), }, { @@ -102,9 +144,7 @@ export const appThemes: AppTheme[] = [ variant: 'light', icon: , Provider: ({ children }) => ( - - {children} - + {children} ), }, ]; diff --git a/packages/portal-backend/README.md b/packages/portal-backend/README.md index 722e819b3..a48e712dd 100644 --- a/packages/portal-backend/README.md +++ b/packages/portal-backend/README.md @@ -6,14 +6,10 @@ custom portal backend is a few lines: ```ts import { createBackend } from '@backstage/backend-defaults'; -import { - portalBackendFeatures, - portalRootHttpRouterServiceFactory, -} from '@openchoreo/backstage-portal-backend'; +import { portalBackendFeatures } from '@openchoreo/backstage-portal-backend'; const backend = createBackend(); -backend.add(portalRootHttpRouterServiceFactory); backend.add(portalBackendFeatures); // Add your own plugins alongside: @@ -24,10 +20,24 @@ backend.start(); ## Exports -- `portalBackendFeatures` — a feature loader bundling the Backstage core - plugins (app, auth, catalog, scaffolder, search, techdocs, permission, - events, proxy, user-settings), the Jenkins CI integration, and all - OpenChoreo backend plugins and modules, in the required registration order. +- `portalBackendFeatures` — a feature loader bundling the portal's root HTTP + router (IDP token middleware), the Backstage core plugins (app, auth, + catalog, scaffolder, search, techdocs, permission, events, proxy, + user-settings), the Jenkins CI integration, and all OpenChoreo backend + plugins and modules, in the required registration order. - `portalRootHttpRouterServiceFactory` — the root HTTP router pre-configured - with the OpenChoreo IDP token header middleware. Kept separate from the - bundle so hosts can substitute their own root-router configuration. + with the OpenChoreo IDP token header middleware. Already part of + `portalBackendFeatures`; exported for hosts composing a custom backend. + +## Substituting your own root HTTP router + +`portalBackendFeatures` includes the root router because the bundled +OpenChoreo permission policy needs its IDP-token middleware — without it, +`openchoreo.*` authorization silently fails closed. Adding a second +root-router factory next to the bundle fails startup with +`Duplicate service implementations provided for core.rootHttpRouter`. + +To bring your own root router, skip the bundle and compose from the exported +building blocks (`portalServiceFactories` + `portalFeatureLoaders`), making +sure your router still applies `createIdpTokenHeaderMiddleware` from +`@openchoreo/openchoreo-auth`. diff --git a/packages/portal-backend/src/features.ts b/packages/portal-backend/src/features.ts index af4253cb6..011578739 100644 --- a/packages/portal-backend/src/features.ts +++ b/packages/portal-backend/src/features.ts @@ -4,6 +4,7 @@ import { immediateCatalogServiceFactory, annotationStoreFactory, } from '@openchoreo/backstage-plugin-catalog-backend-module'; +import { portalRootHttpRouterServiceFactory } from './rootHttpRouter'; /** * OpenChoreo service factories, registered ahead of every plugin: the catalog @@ -97,19 +98,24 @@ export const portalFeatureLoaders = [ * Bundles every backend plugin, module, and service factory the stock portal * runs — Backstage core plugins (app, auth, catalog, scaffolder, search, * techdocs, permission, events, proxy, user-settings), the Jenkins CI - * integration, and all OpenChoreo backend plugins and modules. Add it to a - * backend with a single `backend.add(portalBackendFeatures)`; additional - * features can still be added alongside it with further `backend.add(...)` - * calls. + * integration, and all OpenChoreo backend plugins and modules — plus + * {@link portalRootHttpRouterServiceFactory}, whose IDP-token middleware the + * bundled OpenChoreo permission policy depends on (without it, authorization + * silently fails closed). Add it to a backend with a single + * `backend.add(portalBackendFeatures)`; additional features can still be + * added alongside it with further `backend.add(...)` calls. * - * Note: the portal's root HTTP router middleware is intentionally NOT part of - * this bundle — add {@link portalRootHttpRouterServiceFactory} separately so - * hosts can substitute their own root router configuration. + * To substitute your own root HTTP router configuration, do NOT use this + * bundle (adding a second root-router factory fails startup with a duplicate + * service error) — compose your backend from {@link portalServiceFactories} + * and {@link portalFeatureLoaders} instead, and make sure your router still + * applies `createIdpTokenHeaderMiddleware` from `@openchoreo/openchoreo-auth`. * * @public */ export const portalBackendFeatures = createBackendFeatureLoader({ *loader() { + yield portalRootHttpRouterServiceFactory; yield* portalServiceFactories; for (const load of portalFeatureLoaders) { diff --git a/packages/portal-backend/src/rootHttpRouter.ts b/packages/portal-backend/src/rootHttpRouter.ts index d69fb0fec..88df98d8a 100644 --- a/packages/portal-backend/src/rootHttpRouter.ts +++ b/packages/portal-backend/src/rootHttpRouter.ts @@ -10,6 +10,10 @@ import { createIdpTokenHeaderMiddleware } from '@openchoreo/openchoreo-auth'; * in the permission policy and elsewhere. It must wrap ALL route handlers, * which is why it is registered on the root HTTP router before applyDefaults(). * + * Included in {@link portalBackendFeatures}; exported separately for hosts + * that compose their own backend from `portalServiceFactories` and + * `portalFeatureLoaders`. + * * @public */ export const portalRootHttpRouterServiceFactory = rootHttpRouterServiceFactory({ diff --git a/yarn.lock b/yarn.lock index 2df2b5bf6..721b0ba9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10351,6 +10351,7 @@ __metadata: "@backstage/plugin-user-settings": "npm:^0.9.3" "@backstage/test-utils": "npm:^1.7.18" "@backstage/theme": "npm:^0.7.3" + "@backstage/types": "npm:^1.2.2" "@gitbeaker/rest": "npm:^40.6.0" "@immobiliarelabs/backstage-plugin-gitlab": "npm:^6.13.0" "@material-ui/core": "npm:4.12.4" @@ -17071,6 +17072,7 @@ __metadata: cross-env: "npm:7.0.3" react: "npm:18.3.1" react-dom: "npm:18.3.1" + react-router-dom: "npm:6.30.1" languageName: unknown linkType: soft