Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6912ebe
feat(web): customizable login branding page
thomasbeaudry Jun 3, 2026
654acec
Potential fix for pull request finding
thomasbeaudry Jun 3, 2026
f80ee95
Potential fix for pull request finding
thomasbeaudry Jun 3, 2026
bdf5670
Potential fix for pull request finding
thomasbeaudry Jun 3, 2026
b0f1cb9
Potential fix for pull request finding
thomasbeaudry Jun 3, 2026
b8d09df
Potential fix for pull request finding
thomasbeaudry Jun 3, 2026
5406dba
Potential fix for pull request finding
thomasbeaudry Jun 3, 2026
076e5f4
fix(web): harden resource-link URLs and cheapen the dirty-check
thomasbeaudry Jun 3, 2026
9e58916
Merge remote-tracking branch 'origin/main' into AddBranding
thomasbeaudry Jun 4, 2026
dc95385
fix(lint): satisfy import/exports-last and consistent-indexed-object-…
thomasbeaudry Jun 4, 2026
4e3bf0c
fix(cli): only send Content-Type on requests with a body and increase…
david-roper Jun 5, 2026
178da31
fix: change order of cascading subject deletion to avoid floating ses…
david-roper Jun 5, 2026
284c2e0
fix: rewrite the find method to fix --subject-id behaviour
david-roper Jun 5, 2026
9ad591e
fix: make min_date arg functional in find subjects
david-roper Jun 5, 2026
56e2468
fix: correct instrument-records minDate serialization and subject fin…
david-roper Jun 5, 2026
e158c94
test: add test to confirm order of cascading delete
david-roper Jun 5, 2026
6043405
update Caddyfile and Dockerfile
joshunrau Jun 5, 2026
ccab3ae
increment version
joshunrau Jun 5, 2026
03b0cbb
fix(web): address PR review feedback for login branding
thomasbeaudry Jun 8, 2026
d8c5fa2
merge: resolve version conflicts with main
thomasbeaudry Jun 8, 2026
4f050dc
fix: address remaining PR review comments
thomasbeaudry Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 50 additions & 3 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/setup/dto/update-setup-state.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 15 additions & 3 deletions apps/api/src/setup/setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -67,17 +74,22 @@ export class SetupService {
return { success: true };
}

async updateState(data: UpdateSetupStateData): Promise<Partial<SetupState>> {
async updateState({ branding, ...rest }: UpdateSetupStateData): Promise<Partial<SetupState>> {
const setupState = await this.getSavedOptions();
if (!setupState?.isSetup) {
throw new ServiceUnavailableException('Cannot update state before setup');
}
return this.setupStateModel.update({
data,
const normalizedBranding = branding ? { resourceLinks: [], sectionsOrder: [], ...branding } : branding;
await this.setupStateModel.update({
data: {
...rest,
...(branding !== undefined ? { branding: { set: normalizedBranding ?? null } } : {})
},
Comment on lines +83 to +87
where: {
id: setupState.id
}
});
return this.getState();
}

private async dropDatabase(): Promise<void> {
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/Footer/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<footer className="text-muted-foreground container py-3 text-sm" data-testid="footer">
Expand Down Expand Up @@ -51,7 +51,7 @@ export const Footer = () => {
</div>
</div>
<p className="text-center">
&copy; {CURRENT_YEAR} {t('organization.name')}
&copy; {currentYear} {t('organization.name')}
</p>
</footer>
);
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,28 @@ import { Sidebar } from '../Sidebar';

export const Layout = () => {
return (
<div className="flex h-screen w-screen flex-col md:flex-row" data-testid="layout">
// `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.
<div className="flex h-screen w-full flex-col overflow-clip md:flex-row" data-testid="layout">
<div className="absolute md:hidden">
<Navbar />
</div>
<div className="hidden md:flex md:shrink-0">
<Sidebar />
</div>
<div className="scrollbar-none flex grow flex-col overflow-y-scroll pt-14 md:pt-0" data-testid="layout-main">
{/*
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).
*/}
<div
className="scrollbar-none flex grow flex-col overflow-x-clip overflow-y-scroll pt-14 md:pt-0"
data-testid="layout-main"
>
<main className="container flex grow flex-col">
<Outlet />
</main>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof LoginBrandingPanel>;

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'
}
};

export default { component: LoginBrandingPanel } as Meta<typeof LoginBrandingPanel>;
Loading
Loading