From 6912ebe5d0c2cd46126f0976b73f9ecaaa8c63e6 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Wed, 3 Jun 2026 12:25:31 -0400 Subject: [PATCH 1/9] feat(web): customizable login branding page Add an admin "Customize Login Page" route that lets administrators brand the login screen, rendered live by a new shared by the editor preview and the real login page. Configurable branding: - Instance name, main description (tagline), details, and resource links (bilingual EN/FR), each independently orderable and toggleable. - Logo: choose between an uploaded image or an image URL via a radio, with both slots persisted; falls back to the default logo if a URL 404s. - Per-section font size (10-72px), bold, and name alignment. - Left- and right-panel gradient themes (presets or custom hex) and a single left-panel text color. Also: - Surface the instance name atop the login form when the branding panel is hidden (below lg / high zoom). - Fix horizontal scroll + grey area below the footer (w-screen -> w-full on the layout, which included the scrollbar gutter). - Keep the footer copyright year live via a useCurrentYear hook (the old module-scope constant never rolled over without a reload). - Persist branding via a BrandingConfig composite type (Prisma + Zod), with backward-compatible migration of the legacy single logo field. Co-Authored-By: Claude Opus 4.8 --- apps/api/prisma/schema.prisma | 53 +- .../src/setup/dto/update-setup-state.dto.ts | 7 +- apps/api/src/setup/setup.service.ts | 18 +- apps/web/src/components/Footer/Footer.tsx | 6 +- apps/web/src/components/Layout/Layout.tsx | 18 +- .../LoginBrandingPanel.stories.tsx | 59 + .../LoginBranding/LoginBrandingPanel.tsx | 345 ++++ .../web/src/components/LoginBranding/index.ts | 1 + apps/web/src/hooks/useCurrentYear.ts | 53 + apps/web/src/hooks/useNavItems.ts | 9 + .../src/hooks/useUpdateSetupStateMutation.ts | 16 +- apps/web/src/route-tree.ts | 21 + apps/web/src/routes/_app/admin/branding.tsx | 1386 +++++++++++++++++ apps/web/src/routes/_app/admin/settings.tsx | 3 +- apps/web/src/routes/auth/login.tsx | 71 +- apps/web/src/utils/branding.ts | 56 + packages/schemas/src/setup/setup.ts | 131 +- 17 files changed, 2200 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/components/LoginBranding/LoginBrandingPanel.stories.tsx create mode 100644 apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx create mode 100644 apps/web/src/components/LoginBranding/index.ts create mode 100644 apps/web/src/hooks/useCurrentYear.ts create mode 100644 apps/web/src/routes/_app/admin/branding.tsx create mode 100644 apps/web/src/utils/branding.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 74594c8fd..983c85c3b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -275,10 +275,57 @@ model Session { // Setup +type BrandingText { + en String? + fr String? +} + +type ResourceLink { + href String + label String +} + +type BrandingConfig { + boldDetails Boolean? + boldName Boolean? + boldResourceLinks Boolean? + boldTagline Boolean? + customLogoHeight Int? + customLogoSrc String? + customLogoUrl String? + customLogoWidth Int? + customPrimaryColor String? + customSecondaryColor String? + detailsFontSize Int? + instanceDetails BrandingText? + instanceName BrandingText? + instanceTagline BrandingText? + loginTheme String? + logoAlignment String? + logoSize String? + logoSource String? + nameAlignment String? + nameFontSize Int? + panelTextColor String? + resourceLinks ResourceLink[] + resourceLinksFontSize Int? + rightPanelPrimaryColor String? + rightPanelSecondaryColor String? + rightPanelTheme String? + sectionsOrder String[] + showDetails Boolean? + showFooterLinks Boolean? + showLogo Boolean? + showResourceLinks Boolean? + showTagline Boolean? + taglineFontSize Int? +} + model SetupState { - createdAt DateTime @default(now()) @db.Date - updatedAt DateTime @updatedAt @db.Date - id String @id @default(auto()) @map("_id") @db.ObjectId + createdAt DateTime @default(now()) @db.Date + updatedAt DateTime @updatedAt @db.Date + id String @id @default(auto()) @map("_id") @db.ObjectId + branding BrandingConfig? isDemo Boolean isExperimentalFeaturesEnabled Boolean? isSetup Boolean diff --git a/apps/api/src/setup/dto/update-setup-state.dto.ts b/apps/api/src/setup/dto/update-setup-state.dto.ts index 77407acb5..233e89429 100644 --- a/apps/api/src/setup/dto/update-setup-state.dto.ts +++ b/apps/api/src/setup/dto/update-setup-state.dto.ts @@ -1,10 +1,13 @@ import { ValidationSchema } from '@douglasneuroinformatics/libnest'; import { ApiProperty } from '@nestjs/swagger'; import { $UpdateSetupStateData } from '@opendatacapture/schemas/setup'; -import type { UpdateSetupStateData } from '@opendatacapture/schemas/setup'; +import type { BrandingConfig, UpdateSetupStateData } from '@opendatacapture/schemas/setup'; @ValidationSchema($UpdateSetupStateData) export class UpdateSetupStateDto implements UpdateSetupStateData { - @ApiProperty() + @ApiProperty({ required: false }) + branding?: BrandingConfig | null; + + @ApiProperty({ required: false }) isExperimentalFeaturesEnabled?: boolean; } diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index ac8cb7a0e..0ce4a0172 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -7,6 +7,7 @@ import { InternalServerErrorException, ServiceUnavailableException } from '@nestjs/common'; +import { $BrandingConfig } from '@opendatacapture/schemas/setup'; import type { CreateAdminData, InitAppOptions, SetupState, UpdateSetupStateData } from '@opendatacapture/schemas/setup'; import type { RuntimePrismaClient } from '@/core/prisma'; @@ -37,7 +38,13 @@ export class SetupService { async getState() { const savedOptions = await this.getSavedOptions(); + // The stored value is validated against the schema so that scalar columns + // (e.g. `loginTheme`) are narrowed to their expected literal union types. + // Note: unknown keys are stripped here, so a stale dev server running an + // older $BrandingConfig will silently drop newer branding fields on read. + const branding = $BrandingConfig.nullable().safeParse(savedOptions?.branding ?? null); return { + branding: branding.success ? branding.data : null, isDemo: Boolean(savedOptions?.isDemo), isExperimentalFeaturesEnabled: Boolean(savedOptions?.isExperimentalFeaturesEnabled), isGatewayEnabled: this.configService.get('GATEWAY_ENABLED'), @@ -67,17 +74,22 @@ export class SetupService { return { success: true }; } - async updateState(data: UpdateSetupStateData): Promise> { + async updateState({ branding, ...rest }: UpdateSetupStateData): Promise> { const setupState = await this.getSavedOptions(); if (!setupState?.isSetup) { throw new ServiceUnavailableException('Cannot update state before setup'); } - return this.setupStateModel.update({ - data, + await this.setupStateModel.update({ + data: { + ...rest, + // Composite types must be replaced wholesale via `set` + ...(branding !== undefined ? { branding: { set: branding ?? null } } : {}) + }, where: { id: setupState.id } }); + return this.getState(); } private async dropDatabase(): Promise { diff --git a/apps/web/src/components/Footer/Footer.tsx b/apps/web/src/components/Footer/Footer.tsx index af78d952e..c16a11f74 100644 --- a/apps/web/src/components/Footer/Footer.tsx +++ b/apps/web/src/components/Footer/Footer.tsx @@ -2,11 +2,11 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { Link } from '@tanstack/react-router'; import { config } from '@/config'; - -const CURRENT_YEAR = new Date().getFullYear(); +import { useCurrentYear } from '@/hooks/useCurrentYear'; export const Footer = () => { const { t } = useTranslation('layout'); + const currentYear = useCurrentYear(); return (
@@ -51,7 +51,7 @@ export const Footer = () => {

- © {CURRENT_YEAR} {t('organization.name')} + © {currentYear} {t('organization.name')}

); diff --git a/apps/web/src/components/Layout/Layout.tsx b/apps/web/src/components/Layout/Layout.tsx index 90de61474..79d06768c 100644 --- a/apps/web/src/components/Layout/Layout.tsx +++ b/apps/web/src/components/Layout/Layout.tsx @@ -6,14 +6,28 @@ import { Sidebar } from '../Sidebar'; export const Layout = () => { return ( -
+ // `w-full` (100% of #root, which excludes the scrollbar gutter) rather than + // `w-screen` (100vw, which *includes* it): when a vertical scrollbar is + // present, 100vw is wider than the document and pushes the body sideways, + // exposing the slate body background. `overflow-clip` additionally prevents + // any page or child from extending the layout below the footer. +
-
+ {/* + The main scroll region scrolls vertically. `overflow-x-clip` joins + `overflow-y-scroll` so the region scrolls only along Y while still + clipping any horizontal overflow without breaking `position: sticky` + inside (clip does not create a separate scroll container). + */} +
diff --git a/apps/web/src/components/LoginBranding/LoginBrandingPanel.stories.tsx b/apps/web/src/components/LoginBranding/LoginBrandingPanel.stories.tsx new file mode 100644 index 000000000..78c27feb5 --- /dev/null +++ b/apps/web/src/components/LoginBranding/LoginBrandingPanel.stories.tsx @@ -0,0 +1,59 @@ +import type { BrandingConfig } from '@opendatacapture/schemas/setup'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { LoginBrandingPanel } from './LoginBrandingPanel'; + +type Story = StoryObj; + +export default { component: LoginBrandingPanel } as Meta; + +const baseBranding: BrandingConfig = { + instanceName: { en: 'Open Data Capture', fr: 'Open Data Capture' }, + instanceTagline: { + en: 'A platform for clinical and research data collection.', + fr: 'Une plateforme pour la collecte de données cliniques et de recherche.' + }, + loginTheme: 'ocean' +}; + +export const Default: Story = { + args: { + branding: baseBranding, + className: 'h-screen w-screen' + } +}; + +export const Preview: Story = { + args: { + branding: baseBranding, + className: 'h-96 w-[36rem]', + preview: true + } +}; + +export const WithResources: Story = { + args: { + branding: { + ...baseBranding, + loginTheme: 'midnight', + resourceLinks: [ + { href: 'https://example.org/handbook', label: 'Handbook' }, + { href: 'https://example.org/contact', label: 'Contact' } + ], + showResourceLinks: true + }, + className: 'h-screen w-screen' + } +}; + +export const CustomGradient: Story = { + args: { + branding: { + ...baseBranding, + customPrimaryColor: '#0ea5e9', + customSecondaryColor: '#7c3aed', + loginTheme: 'custom' + }, + className: 'h-screen w-screen' + } +}; diff --git a/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx b/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx new file mode 100644 index 000000000..b06253633 --- /dev/null +++ b/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx @@ -0,0 +1,345 @@ +/** + * The customizable branding panel shown beside the login form (and in the admin + * "Customize Login Page" live preview / fullscreen preview). + * + * Renders the configured `branding` (logo, instance name, tagline, details and + * resource links) over a gradient background. Content sections are emitted in + * the admin-chosen `sectionsOrder`. Most styling derives from the `branding` + * config: per-section font size (inline px, scaled in `preview`), bold flags, + * name alignment, and an optional single text color applied to all text. + * + * `preview` renders a downscaled variant for the small admin preview card; the + * same component at full scale powers the real login page. + */ +import type React from 'react'; +import { useEffect, useState } from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { cn } from '@douglasneuroinformatics/libui/utils'; +import { Logo } from '@opendatacapture/react-core'; +import type { BrandingConfig, LogoAlignment, LogoSize, PanelSection } from '@opendatacapture/schemas/setup'; +import { BookOpenIcon, GithubIcon, LinkIcon } from 'lucide-react'; + +import { config } from '@/config'; +import { useCurrentYear } from '@/hooks/useCurrentYear'; +import { getLoginGradient } from '@/utils/branding'; + +const PRESET_LOGO_HEIGHT_CLASS: { [K in Exclude]: { main: string; preview: string } } = { + large: { main: 'h-28', preview: 'h-14' }, + medium: { main: 'h-20', preview: 'h-10' }, + small: { main: 'h-12', preview: 'h-8' }, + xlarge: { main: 'h-44', preview: 'h-20' } +}; + +const LOGO_ALIGNMENT_CLASS: { [K in LogoAlignment]: string } = { + center: 'justify-center', + left: 'justify-start', + right: 'justify-end' +}; + +const TEXT_ALIGNMENT_CLASS: { [K in LogoAlignment]: string } = { + center: 'text-center', + left: 'text-left', + right: 'text-right' +}; + +const DEFAULT_SECTIONS_ORDER: PanelSection[] = ['logo', 'name', 'tagline', 'details', 'resources']; +/** Scale factor applied to custom font sizes in the small preview card. */ +const PREVIEW_FONT_SCALE = 0.5; +/** Smallest rendered font size (px) in the preview, so tiny choices stay legible. */ +const PREVIEW_FONT_MIN_PX = 7; +/** Linear scale factor applied to custom logo dimensions in the small preview card. */ +const PREVIEW_LOGO_SCALE = 0.18; +/** Hard cap on logo dimensions in the small preview card (prevents huge logos from blowing out the layout). */ +const PREVIEW_LOGO_MAX_PX = 120; + +type LoginBrandingPanelProps = { + branding?: BrandingConfig | null; + className?: string; + /** When provided, overrides the resolved app language — used for live preview language toggle */ + lang?: 'en' | 'fr'; + /** When true, render at a reduced scale suitable for an inline preview */ + preview?: boolean; +}; + +/** Default brand name shown when no `instanceName` is configured. */ +const DEFAULT_INSTANCE_NAME = 'Open Data Capture'; + +export const LoginBrandingPanel = ({ + branding, + className, + lang: langOverride, + preview = false +}: LoginBrandingPanelProps) => { + const { resolvedLanguage, t } = useTranslation(); + const lang = langOverride ?? (resolvedLanguage === 'fr' ? 'fr' : 'en'); + const currentYear = useCurrentYear(); + + /** Translate an inline bilingual object using the (possibly overridden) lang. */ + const tl = (obj: { en: string; fr: string }): string => obj[lang]; + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const instanceName = branding?.instanceName?.[lang]?.trim() || DEFAULT_INSTANCE_NAME; + const instanceTagline = branding?.instanceTagline?.[lang]?.trim() ?? null; + const instanceDetails = branding?.instanceDetails?.[lang]?.trim() ?? null; + const showFooterLinks = branding?.showFooterLinks ?? true; + const showLogo = branding?.showLogo !== false; + const showTagline = branding?.showTagline !== false; + const showDetails = branding?.showDetails !== false; + const boldName = branding?.boldName !== false; + const nameAlignment: LogoAlignment = branding?.nameAlignment ?? 'left'; + const boldTagline = branding?.boldTagline === true; + const boldDetails = branding?.boldDetails === true; + const boldResourceLinks = branding?.boldResourceLinks === true; + const panelTextColor = branding?.panelTextColor ?? null; + + /** + * Returns the given default slate text-color class, or `null` when a custom + * panel text color is configured — in which case the element instead inherits + * the color set inline on the panel root, so every text uses the chosen color. + */ + const tc = (slateClass: string): null | string => (panelTextColor ? null : slateClass); + + // The active logo image: the URL slot when logoSource is 'url', otherwise the + // uploaded-image slot. An absent logoSource (legacy data) falls back to + // `customLogoSrc`, which historically held either an upload or a URL. + const logoSrc = branding?.logoSource === 'url' ? (branding.customLogoUrl ?? null) : (branding?.customLogoSrc ?? null); + + // If a logo *URL* fails to load (e.g. a 404), fall back to the default logo + // instead of showing a broken image. Reset when the active source changes. + const [logoLoadFailed, setLogoLoadFailed] = useState(false); + useEffect(() => { + setLogoLoadFailed(false); + }, [logoSrc]); + + /** + * Inline `font-size` override (px) for a configured section size, scaled down + * in the preview card. Returns `{}` when unset so the default Tailwind size + * class applies. An inline `font-size` always wins over the class, including + * its responsive `lg:` variant. + */ + const fontStyle = (px: null | number | undefined): React.CSSProperties => + px ? { fontSize: `${preview ? Math.max(Math.round(px * PREVIEW_FONT_SCALE), PREVIEW_FONT_MIN_PX) : px}px` } : {}; + const logoSize: LogoSize = branding?.logoSize ?? 'small'; + const logoAlignment: LogoAlignment = branding?.logoAlignment ?? 'left'; + const showResourceLinks = (branding?.showResourceLinks ?? false) && (branding?.resourceLinks?.length ?? 0) > 0; + + const sectionsOrder: PanelSection[] = + branding?.sectionsOrder?.length === DEFAULT_SECTIONS_ORDER.length + ? (branding.sectionsOrder as PanelSection[]) + : DEFAULT_SECTIONS_ORDER; + + // ── Logo sizing ────────────────────────────────────────────────────────── + const baseW = branding?.customLogoWidth ?? null; + const baseH = branding?.customLogoHeight ?? null; + const useCustomSize = logoSize === 'custom' && (baseW !== null || baseH !== null); + + let logoImgClass: string; + let logoImgStyle: React.CSSProperties; + + if (useCustomSize) { + if (preview) { + // Apply a fixed scale so different real-world sizes look visibly different, + // then clamp so a very large logo can't blow out the preview card. + const w = baseW ?? 0; + const h = baseH ?? 0; + const maxDim = Math.max(w, h); + const scale = maxDim > 0 ? Math.min(PREVIEW_LOGO_MAX_PX / maxDim, PREVIEW_LOGO_SCALE) : 1; + logoImgStyle = { + height: h > 0 ? `${Math.round(h * scale)}px` : 'auto', + width: w > 0 ? `${Math.round(w * scale)}px` : 'auto' + }; + } else { + // Full size — exact specified dimensions. + logoImgStyle = { + height: baseH ? `${baseH}px` : 'auto', + width: baseW ? `${baseW}px` : 'auto' + }; + } + // No object-fit class — the element's content stretches (`fill`, the default) + // to fully cover the explicit width × height box the user requested. + logoImgClass = ''; + } else { + const presetKey = logoSize === 'custom' ? 'small' : logoSize; + const heightClass = PRESET_LOGO_HEIGHT_CLASS[presetKey][preview ? 'preview' : 'main']; + logoImgClass = cn('w-auto', heightClass); + logoImgStyle = {}; + } + + // ── Section nodes ──────────────────────────────────────────────────────── + + const logoNode: React.ReactNode = showLogo ? ( +
+ {logoSrc && !logoLoadFailed ? ( + {instanceName} setLogoLoadFailed(true)} + /> + ) : ( + + )} +
+ ) : null; + + const nameNode: React.ReactNode = ( +

+ {instanceName} +

+ ); + + const taglineNode: React.ReactNode = + showTagline && instanceTagline ? ( +

+ {instanceTagline} +

+ ) : null; + + const detailsNode: React.ReactNode = + showDetails && instanceDetails ? ( +

+ {instanceDetails} +

+ ) : null; + + const resourcesNode: React.ReactNode = showResourceLinks ? ( +
+

+ {tl({ en: 'Resources', fr: 'Ressources' })} +

+
+ {branding!.resourceLinks!.map((link, index) => ( + + + {link.label} + + ))} +
+
+ ) : null; + + // Map each section key to its rendered node, then emit them in the + // admin-configured order, skipping any that resolved to `null` (hidden or + // empty). This is what makes the sections independently orderable. + const sectionNodes: { [K in PanelSection]: React.ReactNode } = { + details: detailsNode, + logo: logoNode, + name: nameNode, + resources: resourcesNode, + tagline: taglineNode + }; + + const visibleSections = sectionsOrder.filter((s) => sectionNodes[s] !== null); + + return ( +
+ {/* Decorative shading */} +
+ + {/* Ordered content sections */} +
+ {visibleSections.map((section) => ( +
{sectionNodes[section]}
+ ))} +
+ + {/* Footer — always pinned to the bottom with breathing room */} +
+ {showFooterLinks && ( + + )} +

+ © {currentYear} {t('layout.organization.name')} +

+
+
+ ); +}; diff --git a/apps/web/src/components/LoginBranding/index.ts b/apps/web/src/components/LoginBranding/index.ts new file mode 100644 index 000000000..f04e7d91f --- /dev/null +++ b/apps/web/src/components/LoginBranding/index.ts @@ -0,0 +1 @@ +export * from './LoginBrandingPanel'; diff --git a/apps/web/src/hooks/useCurrentYear.ts b/apps/web/src/hooks/useCurrentYear.ts new file mode 100644 index 000000000..23c26bc74 --- /dev/null +++ b/apps/web/src/hooks/useCurrentYear.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; + +/** The largest delay `setTimeout` accepts (~24.8 days); longer values overflow and fire immediately. */ +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** + * Returns the current calendar year and keeps it current even when the page is + * left open for a long time (e.g. across a New Year boundary). + * + * A plain `new Date().getFullYear()` computed at render — or worse, at module + * load — would otherwise display a stale year until the next reload. This hook + * re-arms a timer at each year boundary and also recomputes when the tab becomes + * visible again, so the displayed year (e.g. a footer copyright) always rolls over. + */ +export function useCurrentYear(): number { + const [year, setYear] = useState(() => new Date().getFullYear()); + + useEffect(() => { + let timer: number; + + // Schedule an update for the next Jan 1. Because the delay until then can + // exceed setTimeout's max, clamp it and re-arm — each tick either rolls the + // year over or just re-schedules until the boundary is actually reached. + const schedule = () => { + const now = new Date(); + const msUntilNewYear = new Date(now.getFullYear() + 1, 0, 1).getTime() - now.getTime(); + timer = window.setTimeout( + () => { + setYear(new Date().getFullYear()); + schedule(); + }, + Math.min(msUntilNewYear, MAX_TIMEOUT_MS) + ); + }; + schedule(); + + // Cheap catch-all: a backgrounded tab's timers can be throttled, so refresh + // the year whenever the tab is shown again. + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + setYear(new Date().getFullYear()); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + + return () => { + window.clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, []); + + return year; +} diff --git a/apps/web/src/hooks/useNavItems.ts b/apps/web/src/hooks/useNavItems.ts index 3162dd261..98e6d3867 100644 --- a/apps/web/src/hooks/useNavItems.ts +++ b/apps/web/src/hooks/useNavItems.ts @@ -9,6 +9,7 @@ import { DatabaseIcon, EyeIcon, LogsIcon, + PaletteIcon, UploadIcon, UserCogIcon, UsersIcon @@ -90,6 +91,14 @@ export function useNavItems() { }), url: '/admin/settings' }); + adminItems.push({ + icon: PaletteIcon, + label: t({ + en: 'Customize Login Page', + fr: 'Personnaliser la page de connexion' + }), + url: '/admin/branding' + }); adminItems.push({ icon: LogsIcon, label: t('common.auditLogs'), diff --git a/apps/web/src/hooks/useUpdateSetupStateMutation.ts b/apps/web/src/hooks/useUpdateSetupStateMutation.ts index 88037c704..c24a04ec0 100644 --- a/apps/web/src/hooks/useUpdateSetupStateMutation.ts +++ b/apps/web/src/hooks/useUpdateSetupStateMutation.ts @@ -5,7 +5,15 @@ import axios from 'axios'; import { SETUP_STATE_QUERY_KEY } from './useSetupStateQuery'; -export function useUpdateSetupStateMutation() { +type UpdateSetupStateMutationOptions = { + /** The notification shown when the update succeeds */ + successNotification?: { + message: string; + title: string; + }; +}; + +export function useUpdateSetupStateMutation({ successNotification }: UpdateSetupStateMutationOptions = {}) { const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); return useMutation({ @@ -13,7 +21,11 @@ export function useUpdateSetupStateMutation() { await axios.patch('/v1/setup', data); }, onSuccess() { - addNotification({ type: 'success' }); + addNotification({ + message: successNotification?.message, + title: successNotification?.title, + type: 'success' + }); void queryClient.invalidateQueries({ queryKey: [SETUP_STATE_QUERY_KEY] }); } }); diff --git a/apps/web/src/route-tree.ts b/apps/web/src/route-tree.ts index 2ebe4eecf..7946e4a27 100644 --- a/apps/web/src/route-tree.ts +++ b/apps/web/src/route-tree.ts @@ -24,6 +24,7 @@ import { Route as AppSessionStartSessionRouteImport } from './routes/_app/sessio import { Route as AppInstrumentsAccessibleInstrumentsRouteImport } from './routes/_app/instruments/accessible-instruments' import { Route as AppGroupManageRouteImport } from './routes/_app/group/manage' import { Route as AppAdminSettingsRouteImport } from './routes/_app/admin/settings' +import { Route as AppAdminBrandingRouteImport } from './routes/_app/admin/branding' import { Route as AppDatahubSubjectIdRouteRouteImport } from './routes/_app/datahub/$subjectId/route' import { Route as AppAdminUsersIndexRouteImport } from './routes/_app/admin/users/index' import { Route as AppAdminGroupsIndexRouteImport } from './routes/_app/admin/groups/index' @@ -111,6 +112,11 @@ const AppAdminSettingsRoute = AppAdminSettingsRouteImport.update({ path: '/admin/settings', getParentRoute: () => AppRouteRoute, } as any) +const AppAdminBrandingRoute = AppAdminBrandingRouteImport.update({ + id: '/admin/branding', + path: '/admin/branding', + getParentRoute: () => AppRouteRoute, +} as any) const AppDatahubSubjectIdRouteRoute = AppDatahubSubjectIdRouteRouteImport.update({ id: '/datahub/$subjectId', @@ -181,6 +187,7 @@ export interface FileRoutesByFullPath { '/user': typeof AppUserRoute '/auth/login': typeof AuthLoginRoute '/datahub/$subjectId': typeof AppDatahubSubjectIdRouteRouteWithChildren + '/admin/branding': typeof AppAdminBrandingRoute '/admin/settings': typeof AppAdminSettingsRoute '/group/manage': typeof AppGroupManageRoute '/instruments/accessible-instruments': typeof AppInstrumentsAccessibleInstrumentsRoute @@ -208,6 +215,7 @@ export interface FileRoutesByTo { '/auth/login': typeof AuthLoginRoute '/': typeof AppIndexRoute '/datahub/$subjectId': typeof AppDatahubSubjectIdRouteRouteWithChildren + '/admin/branding': typeof AppAdminBrandingRoute '/admin/settings': typeof AppAdminSettingsRoute '/group/manage': typeof AppGroupManageRoute '/instruments/accessible-instruments': typeof AppInstrumentsAccessibleInstrumentsRoute @@ -237,6 +245,7 @@ export interface FileRoutesById { '/auth/login': typeof AuthLoginRoute '/_app/': typeof AppIndexRoute '/_app/datahub/$subjectId': typeof AppDatahubSubjectIdRouteRouteWithChildren + '/_app/admin/branding': typeof AppAdminBrandingRoute '/_app/admin/settings': typeof AppAdminSettingsRoute '/_app/group/manage': typeof AppGroupManageRoute '/_app/instruments/accessible-instruments': typeof AppInstrumentsAccessibleInstrumentsRoute @@ -266,6 +275,7 @@ export interface FileRouteTypes { | '/user' | '/auth/login' | '/datahub/$subjectId' + | '/admin/branding' | '/admin/settings' | '/group/manage' | '/instruments/accessible-instruments' @@ -293,6 +303,7 @@ export interface FileRouteTypes { | '/auth/login' | '/' | '/datahub/$subjectId' + | '/admin/branding' | '/admin/settings' | '/group/manage' | '/instruments/accessible-instruments' @@ -321,6 +332,7 @@ export interface FileRouteTypes { | '/auth/login' | '/_app/' | '/_app/datahub/$subjectId' + | '/_app/admin/branding' | '/_app/admin/settings' | '/_app/group/manage' | '/_app/instruments/accessible-instruments' @@ -453,6 +465,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAdminSettingsRouteImport parentRoute: typeof AppRouteRoute } + '/_app/admin/branding': { + id: '/_app/admin/branding' + path: '/admin/branding' + fullPath: '/admin/branding' + preLoaderRoute: typeof AppAdminBrandingRouteImport + parentRoute: typeof AppRouteRoute + } '/_app/datahub/$subjectId': { id: '/_app/datahub/$subjectId' path: '/datahub/$subjectId' @@ -560,6 +579,7 @@ interface AppRouteRouteChildren { AppUserRoute: typeof AppUserRoute AppIndexRoute: typeof AppIndexRoute AppDatahubSubjectIdRouteRoute: typeof AppDatahubSubjectIdRouteRouteWithChildren + AppAdminBrandingRoute: typeof AppAdminBrandingRoute AppAdminSettingsRoute: typeof AppAdminSettingsRoute AppGroupManageRoute: typeof AppGroupManageRoute AppInstrumentsAccessibleInstrumentsRoute: typeof AppInstrumentsAccessibleInstrumentsRoute @@ -582,6 +602,7 @@ const AppRouteRouteChildren: AppRouteRouteChildren = { AppUserRoute: AppUserRoute, AppIndexRoute: AppIndexRoute, AppDatahubSubjectIdRouteRoute: AppDatahubSubjectIdRouteRouteWithChildren, + AppAdminBrandingRoute: AppAdminBrandingRoute, AppAdminSettingsRoute: AppAdminSettingsRoute, AppGroupManageRoute: AppGroupManageRoute, AppInstrumentsAccessibleInstrumentsRoute: diff --git a/apps/web/src/routes/_app/admin/branding.tsx b/apps/web/src/routes/_app/admin/branding.tsx new file mode 100644 index 000000000..1d1fba6c8 --- /dev/null +++ b/apps/web/src/routes/_app/admin/branding.tsx @@ -0,0 +1,1386 @@ +/** + * Admin "Customize Login Page" route. + * + * Lets an administrator brand the login page: instance name/tagline/details, + * logo, resource links, panel gradients, per-section font sizes, bold and + * alignment, and the left-panel text color. All edits live in local `form` + * state and are rendered live by (left) plus a mock form + * (right); the same shape is persisted via the setup-state mutation on Save. + * + * A few cross-cutting concerns to keep in mind when editing: + * - `form` is the single source of truth; `previewBranding` and the submit + * payload are both derived from it (keep the two in sync when adding fields). + * - An unsaved-changes guard (useBlocker) compares a JSON snapshot of `form`. + * - Empty strings are normalized to `null` on save so the panel uses defaults. + */ +import React, { useRef, useState } from 'react'; + +import { + Button, + Card, + Checkbox, + Dialog, + Heading, + Input, + Label, + RadioGroup, + Select, + Tabs, + TextArea +} from '@douglasneuroinformatics/libui/components'; +import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { cn } from '@douglasneuroinformatics/libui/utils'; +import { Logo } from '@opendatacapture/react-core'; +import { FONT_SIZES, LOGIN_THEMES, LOGO_ALIGNMENTS, LOGO_SIZES, PANEL_SECTIONS } from '@opendatacapture/schemas/setup'; +import type { + BrandingConfig, + BrandingText, + LoginTheme, + LogoAlignment, + LogoSize, + LogoSource, + PanelSection, + ResourceLink +} from '@opendatacapture/schemas/setup'; +import { createFileRoute, useBlocker } from '@tanstack/react-router'; +import { ChevronDownIcon, ChevronUpIcon, MaximizeIcon, PlusIcon, TrashIcon, UploadIcon, XIcon } from 'lucide-react'; + +import { LoginBrandingPanel } from '@/components/LoginBranding'; +import { PageHeader } from '@/components/PageHeader'; +import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; +import { useUpdateSetupStateMutation } from '@/hooks/useUpdateSetupStateMutation'; +import { getLoginGradient, getRightPanelGradient, LOGIN_THEME_COLORS } from '@/utils/branding'; + +/** + * Options for the right-panel theme picker: 'default' (no override) + a curated + * subset of LoginTheme values. Sunset is intentionally omitted to keep the + * swatch grid at 8 cells (2 rows × 4 columns). + */ +const RIGHT_PANEL_OPTIONS = ['none', 'slate', 'ocean', 'forest', 'violet', 'rose', 'midnight', 'custom'] as const; +type RightPanelOption = (typeof RIGHT_PANEL_OPTIONS)[number]; + +const RIGHT_PANEL_LABELS: { [K in RightPanelOption]: { en: string; fr: string } } = { + custom: { en: 'Custom', fr: 'Personnalisé' }, + forest: { en: 'Forest', fr: 'Forêt' }, + midnight: { en: 'Midnight', fr: 'Minuit' }, + none: { en: 'Default', fr: 'Par défaut' }, + ocean: { en: 'Ocean', fr: 'Océan' }, + rose: { en: 'Rose', fr: 'Rose' }, + slate: { en: 'Slate', fr: 'Ardoise' }, + violet: { en: 'Violet', fr: 'Violet' } +}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const THEME_LABELS: { [K in LoginTheme]: { en: string; fr: string } } = { + custom: { en: 'Custom', fr: 'Personnalisé' }, + forest: { en: 'Forest', fr: 'Forêt' }, + midnight: { en: 'Midnight', fr: 'Minuit' }, + ocean: { en: 'Ocean', fr: 'Océan' }, + rose: { en: 'Rose', fr: 'Rose' }, + slate: { en: 'Slate', fr: 'Ardoise' }, + sunset: { en: 'Sunset', fr: 'Coucher de soleil' }, + violet: { en: 'Violet', fr: 'Violet' } +}; + +const LOGO_SIZE_LABELS: { [K in LogoSize]: { en: string; fr: string } } = { + custom: { en: 'Custom', fr: 'Personnalisé' }, + large: { en: 'Large', fr: 'Grand' }, + medium: { en: 'Medium', fr: 'Moyen' }, + small: { en: 'Small', fr: 'Petit' }, + xlarge: { en: 'Extra Large', fr: 'Très grand' } +}; + +const LOGO_ALIGNMENT_LABELS: { [K in LogoAlignment]: { en: string; fr: string } } = { + center: { en: 'Center', fr: 'Centre' }, + left: { en: 'Left', fr: 'Gauche' }, + right: { en: 'Right', fr: 'Droite' } +}; + +const SECTION_TITLES: { [K in PanelSection]: { en: string; fr: string } } = { + details: { en: 'Details', fr: 'Détails' }, + logo: { en: 'Logo', fr: 'Logo' }, + name: { en: 'Instance Name', fr: "Nom de l'instance" }, + resources: { en: 'Resources', fr: 'Ressources' }, + tagline: { en: 'Main Description', fr: 'Description principale' } +}; + +const DEFAULT_SECTIONS_ORDER: PanelSection[] = ['logo', 'name', 'tagline', 'details', 'resources']; +const HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; +/** Accept http(s) URLs with a hostname containing at least one dot (e.g. example.com). */ +const URL_PATTERN = /^https?:\/\/\S+\.\S+$/; +const MAX_LOGO_BYTES = 1024 * 1024; +const FORM_ID = 'branding-form'; +/** Sentinel Select value representing "no override — use the default font size". */ +const FONT_SIZE_DEFAULT = 'default'; +/** Seed color for the left-panel text picker — matches the panel's default `text-slate-100`. */ +const DEFAULT_PANEL_TEXT_COLOR = '#f1f5f9'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type FormState = { + boldDetails: boolean; + boldName: boolean; + boldResourceLinks: boolean; + boldTagline: boolean; + customLogoHeight: string; + /** Uploaded logo image as a data URI (the 'upload' slot) */ + customLogoSrc: string; + /** External logo image URL (the 'url' slot) */ + customLogoUrl: string; + customLogoWidth: string; + customPrimaryColor: string; + customSecondaryColor: string; + /** Per-section font size (px), or null to use the default size */ + detailsFontSize: null | number; + instanceDetails: { en: string; fr: string }; + instanceName: { en: string; fr: string }; + instanceTagline: { en: string; fr: string }; + loginTheme: LoginTheme; + logoAlignment: LogoAlignment; + logoSize: LogoSize; + /** Which logo slot is active: 'upload' or 'url' */ + logoSource: LogoSource; + nameAlignment: LogoAlignment; + nameFontSize: null | number; + /** Hex color applied to all left-panel text (always set; seeded with the default). */ + panelTextColor: string; + resourceLinks: ResourceLink[]; + resourceLinksFontSize: null | number; + /** 'none' means no right-panel override; otherwise one of LoginTheme */ + rightPanelOption: RightPanelOption; + rightPanelPrimaryColor: string; + rightPanelSecondaryColor: string; + sectionsOrder: PanelSection[]; + showDetails: boolean; + showFooterLinks: boolean; + showLogo: boolean; + showResourceLinks: boolean; + showTagline: boolean; + taglineFontSize: null | number; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +const RouteComponent = () => { + const { t } = useTranslation(); + const addNotification = useNotificationsStore((store) => store.addNotification); + const setupStateQuery = useSetupStateQuery(); + const fileInputRef = useRef(null); + const [showFullscreen, setShowFullscreen] = useState(false); + const [previewLang, setPreviewLang] = useState<'en' | 'fr'>('en'); + + const updateSetupStateMutation = useUpdateSetupStateMutation({ + successNotification: { + message: t({ en: 'The login page has been updated.', fr: 'La page de connexion a été mise à jour.' }), + title: t({ en: 'Success', fr: 'Succès' }) + } + }); + + const saved = setupStateQuery.data.branding; + + const [form, setForm] = useState(() => { + // Migrate legacy data: before the upload/URL split, a single `customLogoSrc` + // held *either* an uploaded data URI or a URL. Route a legacy URL into the + // new URL slot so both slots stay correct going forward. + const savedLogoSrc = saved?.customLogoSrc ?? ''; + const savedLogoUrl = saved?.customLogoUrl ?? ''; + const legacyUrlInSrc = !savedLogoUrl && savedLogoSrc !== '' && !savedLogoSrc.startsWith('data:'); + return { + boldDetails: saved?.boldDetails === true, + boldName: saved?.boldName !== false, + boldResourceLinks: saved?.boldResourceLinks === true, + boldTagline: saved?.boldTagline === true, + customLogoHeight: saved?.customLogoHeight ? String(saved.customLogoHeight) : '', + customLogoSrc: legacyUrlInSrc ? '' : savedLogoSrc, + customLogoUrl: legacyUrlInSrc ? savedLogoSrc : savedLogoUrl, + customLogoWidth: saved?.customLogoWidth ? String(saved.customLogoWidth) : '', + customPrimaryColor: saved?.customPrimaryColor ?? LOGIN_THEME_COLORS.ocean.primary, + customSecondaryColor: saved?.customSecondaryColor ?? LOGIN_THEME_COLORS.ocean.secondary, + detailsFontSize: saved?.detailsFontSize ?? null, + instanceDetails: { en: saved?.instanceDetails?.en ?? '', fr: saved?.instanceDetails?.fr ?? '' }, + instanceName: { en: saved?.instanceName?.en ?? '', fr: saved?.instanceName?.fr ?? '' }, + instanceTagline: { en: saved?.instanceTagline?.en ?? '', fr: saved?.instanceTagline?.fr ?? '' }, + loginTheme: saved?.loginTheme ?? 'slate', + logoAlignment: saved?.logoAlignment ?? 'left', + logoSize: saved?.logoSize ?? 'small', + logoSource: saved?.logoSource ?? (legacyUrlInSrc ? 'url' : 'upload'), + nameAlignment: saved?.nameAlignment ?? 'left', + nameFontSize: saved?.nameFontSize ?? null, + panelTextColor: saved?.panelTextColor ?? DEFAULT_PANEL_TEXT_COLOR, + resourceLinks: saved?.resourceLinks?.length ? saved.resourceLinks.map((l) => ({ ...l })) : [], + resourceLinksFontSize: saved?.resourceLinksFontSize ?? null, + rightPanelOption: (saved?.rightPanelTheme ?? 'none') as RightPanelOption, + rightPanelPrimaryColor: saved?.rightPanelPrimaryColor ?? LOGIN_THEME_COLORS.slate.primary, + rightPanelSecondaryColor: saved?.rightPanelSecondaryColor ?? LOGIN_THEME_COLORS.slate.secondary, + sectionsOrder: + saved?.sectionsOrder?.length === PANEL_SECTIONS.length + ? (saved.sectionsOrder as PanelSection[]) + : DEFAULT_SECTIONS_ORDER, + showDetails: saved?.showDetails !== false, + showFooterLinks: saved?.showFooterLinks ?? true, + showLogo: saved?.showLogo !== false, + showResourceLinks: saved?.showResourceLinks ?? false, + showTagline: saved?.showTagline !== false, + taglineFontSize: saved?.taglineFontSize ?? null + }; + }); + + // ── Unsaved-changes guard ─────────────────────────────────────────────── + // Snapshot of the form state as it appeared when last saved (or on mount). + // Used to detect unsaved edits and warn the user before they navigate away. + const savedSnapshotRef = useRef(JSON.stringify(form)); + const isDirty = JSON.stringify(form) !== savedSnapshotRef.current; + + // Block in-app navigation (TanStack Router back button, link clicks, etc.) + // and the native `beforeunload` (tab close / refresh / external nav) when dirty. + useBlocker({ + enableBeforeUnload: () => isDirty, + shouldBlockFn: () => { + if (!isDirty) return false; + // A native confirm() is intentional here: useBlocker needs a synchronous + // boolean decision, which a custom async dialog can't provide. + // eslint-disable-next-line no-alert + return !window.confirm( + t({ + en: 'You have unsaved changes to the login page. Are you sure you want to leave? Your changes will be lost.', + fr: 'Vous avez des modifications non enregistrées. Voulez-vous vraiment quitter ? Vos modifications seront perdues.' + }) + ); + } + }); + + // ── State helpers ──────────────────────────────────────────────────────── + + const update = (key: K, value: FormState[K]) => + setForm((prev) => ({ ...prev, [key]: value })); + + const updateText = ( + field: 'instanceDetails' | 'instanceName' | 'instanceTagline', + lang: 'en' | 'fr', + value: string + ) => setForm((prev) => ({ ...prev, [field]: { ...prev[field], [lang]: value } })); + + const updateResourceLink = (index: number, key: keyof ResourceLink, value: string) => + setForm((prev) => ({ + ...prev, + resourceLinks: prev.resourceLinks.map((l, i) => (i === index ? { ...l, [key]: value } : l)) + })); + + const addResourceLink = () => + setForm((prev) => ({ ...prev, resourceLinks: [...prev.resourceLinks, { href: '', label: '' }] })); + + const removeResourceLink = (index: number) => + setForm((prev) => ({ ...prev, resourceLinks: prev.resourceLinks.filter((_, i) => i !== index) })); + + const moveSection = (fromIndex: number, toIndex: number) => + setForm((prev) => { + const order = [...prev.sectionsOrder]; + const [item] = order.splice(fromIndex, 1); + order.splice(toIndex, 0, item!); + return { ...prev, sectionsOrder: order }; + }); + + const handleLogoFile = (file: File | undefined) => { + if (!file) return; + if (file.size > MAX_LOGO_BYTES) { + addNotification({ + message: t({ en: 'The selected image is larger than 1 MB.', fr: "L'image sélectionnée dépasse 1 Mo." }), + title: t({ en: 'File too large', fr: 'Fichier trop volumineux' }), + type: 'error' + }); + return; + } + const reader = new FileReader(); + reader.onload = () => update('customLogoSrc', reader.result as string); + reader.readAsDataURL(file); + }; + + const parseDimension = (v: string): null | number => { + const n = Number.parseInt(v.trim(), 10); + return !v.trim() || Number.isNaN(n) || n <= 0 || n > 5000 ? null : n; + }; + + // ── Derived ────────────────────────────────────────────────────────────── + + const customWidth = parseDimension(form.customLogoWidth); + const customHeight = parseDimension(form.customLogoHeight); + + const isCustomSizeInvalid = + form.logoSize === 'custom' && + ((form.customLogoWidth.trim() !== '' && customWidth === null) || + (form.customLogoHeight.trim() !== '' && customHeight === null) || + (customWidth === null && customHeight === null)); + + const isCustomColorInvalid = + form.loginTheme === 'custom' && + (!HEX_PATTERN.test(form.customPrimaryColor) || !HEX_PATTERN.test(form.customSecondaryColor)); + + const isRightPanelCustomColorInvalid = + form.rightPanelOption === 'custom' && + (!HEX_PATTERN.test(form.rightPanelPrimaryColor) || !HEX_PATTERN.test(form.rightPanelSecondaryColor)); + + // The left-panel text color is always shown (no enable toggle), so it just + // needs to be a valid hex; an empty/garbage value disables Save. + const isPanelTextColorInvalid = !HEX_PATTERN.test(form.panelTextColor); + + const isResourceLinkHrefInvalid = (href: string): boolean => { + const trimmed = href.trim(); + return !trimmed || !URL_PATTERN.test(trimmed); + }; + const hasInvalidResourceLinks = + form.showResourceLinks && form.resourceLinks.some((l) => !l.label.trim() || isResourceLinkHrefInvalid(l.href)); + + const isSubmitDisabled = + isCustomColorInvalid || + isRightPanelCustomColorInvalid || + isPanelTextColorInvalid || + isCustomSizeInvalid || + hasInvalidResourceLinks || + updateSetupStateMutation.isPending; + + // Live-preview branding object. Mirrors the shape persisted by handleSubmit so + // the on-screen preview reflects exactly what saving would produce. Unlike the + // submit payload, it keeps the raw form values (e.g. an in-progress logo URL) + // so the preview updates as the admin types; the `null` coalescing only guards + // values the panel can't render (empty logo, non-custom theme colors). + const previewBranding: BrandingConfig = { + boldDetails: form.boldDetails, + boldName: form.boldName, + boldResourceLinks: form.boldResourceLinks, + boldTagline: form.boldTagline, + customLogoHeight: customHeight, + customLogoSrc: form.customLogoSrc || null, + customLogoUrl: form.customLogoUrl || null, + customLogoWidth: customWidth, + customPrimaryColor: form.customPrimaryColor, + customSecondaryColor: form.customSecondaryColor, + detailsFontSize: form.detailsFontSize, + instanceDetails: form.instanceDetails, + instanceName: form.instanceName, + instanceTagline: form.instanceTagline, + loginTheme: form.loginTheme, + logoAlignment: form.logoAlignment, + logoSize: form.logoSize, + logoSource: form.logoSource, + nameAlignment: form.nameAlignment, + nameFontSize: form.nameFontSize, + panelTextColor: HEX_PATTERN.test(form.panelTextColor) ? form.panelTextColor : null, + resourceLinks: form.resourceLinks, + resourceLinksFontSize: form.resourceLinksFontSize, + rightPanelPrimaryColor: form.rightPanelOption === 'custom' ? form.rightPanelPrimaryColor : null, + rightPanelSecondaryColor: form.rightPanelOption === 'custom' ? form.rightPanelSecondaryColor : null, + rightPanelTheme: form.rightPanelOption === 'none' ? null : form.rightPanelOption, + sectionsOrder: form.sectionsOrder, + showDetails: form.showDetails, + showFooterLinks: form.showFooterLinks, + showLogo: form.showLogo, + showResourceLinks: form.showResourceLinks, + showTagline: form.showTagline, + taglineFontSize: form.taglineFontSize + }; + + // ── Submit ─────────────────────────────────────────────────────────────── + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + // Guard again on submit in case Enter bypassed the disabled Save button. + if ( + isCustomColorInvalid || + isRightPanelCustomColorInvalid || + isPanelTextColorInvalid || + isCustomSizeInvalid || + hasInvalidResourceLinks + ) + return; + // Empty text fields persist as `null` rather than '' so the panel falls back + // to its defaults; a BrandingText is omitted entirely when both langs are blank. + const trim = (v: string) => (v.trim() ? v.trim() : null); + const toText = (text: { en: string; fr: string }): BrandingText | null => { + const en = trim(text.en); + const fr = trim(text.fr); + return (en ?? fr) ? { en, fr } : null; + }; + // Drop blank/partial resource links so we never persist half-filled rows. + const cleanedLinks = form.resourceLinks + .map((l) => ({ href: l.href.trim(), label: l.label.trim() })) + .filter((l) => l.href && l.label); + // Capture the snapshot at submit time so a concurrent edit during the + // request doesn't get marked clean if the user typed while saving. + const submittedSnapshot = JSON.stringify(form); + updateSetupStateMutation.mutate( + { + branding: { + boldDetails: form.boldDetails, + boldName: form.boldName, + boldResourceLinks: form.boldResourceLinks, + boldTagline: form.boldTagline, + customLogoHeight: form.logoSize === 'custom' ? customHeight : null, + // Both logo slots are persisted so each is remembered; `logoSource` picks the active one. + customLogoSrc: form.customLogoSrc.trim() || null, + customLogoUrl: form.customLogoUrl.trim() || null, + customLogoWidth: form.logoSize === 'custom' ? customWidth : null, + customPrimaryColor: form.loginTheme === 'custom' ? form.customPrimaryColor : null, + customSecondaryColor: form.loginTheme === 'custom' ? form.customSecondaryColor : null, + detailsFontSize: form.detailsFontSize, + instanceDetails: toText(form.instanceDetails), + instanceName: toText(form.instanceName), + instanceTagline: toText(form.instanceTagline), + loginTheme: form.loginTheme, + logoAlignment: form.logoAlignment, + logoSize: form.logoSize, + logoSource: form.logoSource, + nameAlignment: form.nameAlignment, + nameFontSize: form.nameFontSize, + panelTextColor: HEX_PATTERN.test(form.panelTextColor) ? form.panelTextColor : null, + resourceLinks: form.showResourceLinks ? cleanedLinks : [], + resourceLinksFontSize: form.resourceLinksFontSize, + rightPanelPrimaryColor: form.rightPanelOption === 'custom' ? form.rightPanelPrimaryColor : null, + rightPanelSecondaryColor: form.rightPanelOption === 'custom' ? form.rightPanelSecondaryColor : null, + rightPanelTheme: form.rightPanelOption === 'none' ? null : form.rightPanelOption, + sectionsOrder: form.sectionsOrder, + showDetails: form.showDetails, + showFooterLinks: form.showFooterLinks, + showLogo: form.showLogo, + showResourceLinks: form.showResourceLinks && cleanedLinks.length > 0, + showTagline: form.showTagline, + taglineFontSize: form.taglineFontSize + } + }, + { + onSuccess: () => { + savedSnapshotRef.current = submittedSnapshot; + } + } + ); + }; + + // ── Section card renderer ──────────────────────────────────────────────── + + const orderButtons = (section: PanelSection) => { + const idx = form.sectionsOrder.indexOf(section); + return ( +
+ + +
+ ); + }; + + // Font-size dropdown reused by every text section. `null` = use default size. + const fontSizeField = (id: string, value: null | number, onChange: (v: null | number) => void) => ( +
+ + +
+ ); + + // Bold on/off toggle shown next to a section's "Show" checkbox. + const boldToggle = (id: string, checked: boolean, onChange: (b: boolean) => void) => ( +
+ onChange(c === true)} /> + +
+ ); + + const renderSectionCard = (section: PanelSection): React.ReactNode => { + switch (section) { + case 'details': + return ( + + +
+
+
+ {t(SECTION_TITLES.details)} +
+ update('showDetails', checked === true)} + /> + +
+ {boldToggle('boldDetails', form.boldDetails, (b) => update('boldDetails', b))} +
+ + {t({ + en: 'Additional notes shown on the branding panel', + fr: 'Remarques supplémentaires figurant sur le panneau de marque.' + })} + +
+ {orderButtons('details')} +
+
+ {form.showDetails && ( + +
+
+ +