feat: add custom branding for signing pages (#2785)

Platform-plan organisations and teams can now customise non-embed
signing pages with six brand colour tokens, a border-radius, and
a free-text custom CSS block (up to 256 KB).

- Stored on OrganisationGlobalSettings / TeamGlobalSettings;
  teams inherit from the org via brandingEnabled === null.
- CSS is sanitised on save (PostCSS) so we can inline it at SSR
  with no per-render parsing.
- Rendered via a nonce'd <style> scoped under .documenso-branded,
  using native CSS nesting so user selectors don't need scoping.
- Gated on the existing embedSigningWhiteLabel claim (or
  self-hosted) — reuses the embed white-label decision.
This commit is contained in:
Lucas Smith
2026-05-11 13:03:02 +10:00
committed by GitHub
parent a197bf113f
commit 0b86ece1d5
37 changed files with 2055 additions and 301 deletions
@@ -1,6 +1,8 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { type SanitizeBrandingCssWarning, sanitizeBrandingCss } from '@documenso/lib/utils/sanitize-branding-css';
import { prisma } from '@documenso/prisma';
import { OrganisationType, Prisma } from '@prisma/client';
@@ -45,6 +47,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
brandingCss,
// Email related settings.
emailId,
@@ -127,6 +131,24 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
});
}
// Sanitize custom branding CSS at write time so we can store the safe
// result and skip per-render sanitisation. Warnings are returned to the
// UI so the user can see what was dropped.
let cssWarnings: SanitizeBrandingCssWarning[] | undefined;
let sanitizedBrandingCss: string | undefined;
if (brandingCss !== undefined) {
const result = sanitizeBrandingCss(brandingCss);
sanitizedBrandingCss = result.css;
cssWarnings = result.warnings;
}
// Strip empty-string colour values; collapse to `null` when the payload
// contains no overrides. Keeps the stored row clean and avoids storing
// `{}` as a real "override of nothing" (matters more for teams, but the
// org row stays tidy this way too).
const normalizedBrandingColors = normalizeBrandingColors(brandingColors);
await prisma.organisation.update({
where: {
id: organisationId,
@@ -155,6 +177,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
brandingCss: sanitizedBrandingCss,
// Email related settings.
emailId,
@@ -168,4 +192,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
},
},
});
return {
cssWarnings: cssWarnings && cssWarnings.length > 0 ? cssWarnings : undefined,
};
});
@@ -1,10 +1,13 @@
import { BRANDING_CSS_MAX_LENGTH } from '@documenso/lib/constants/branding';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZDocumentMetaDateFormatSchema, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZSanitizeBrandingCssWarningSchema } from '@documenso/lib/utils/sanitize-branding-css';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
@@ -32,6 +35,8 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
brandingLogo: z.string().optional(),
brandingUrl: z.string().optional(),
brandingCompanyDetails: z.string().optional(),
brandingColors: ZCssVarsSchema.nullish(),
brandingCss: z.string().max(BRANDING_CSS_MAX_LENGTH).optional(),
// Email related settings.
emailId: z.string().nullish(),
@@ -44,4 +49,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
}),
});
export const ZUpdateOrganisationSettingsResponseSchema = z.void();
export const ZUpdateOrganisationSettingsResponseSchema = z.object({
cssWarnings: z.array(ZSanitizeBrandingCssWarningSchema).optional(),
});
@@ -1,7 +1,9 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { type SanitizeBrandingCssWarning, sanitizeBrandingCss } from '@documenso/lib/utils/sanitize-branding-css';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { OrganisationType, Prisma } from '@prisma/client';
@@ -43,6 +45,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
brandingCss,
// Email related settings.
emailId,
@@ -127,6 +131,27 @@ export const updateTeamSettingsRoute = authenticatedProcedure
});
}
// Sanitize custom branding CSS at write time. `null` means inherit-from-org
// for teams, so only run the sanitiser when an explicit string is provided.
// An empty string after sanitisation is collapsed to `null` so the team
// row inherits rather than persisting an empty override.
let cssWarnings: SanitizeBrandingCssWarning[] | undefined;
let sanitizedBrandingCss: string | null | undefined;
if (brandingCss === null) {
sanitizedBrandingCss = null;
} else if (typeof brandingCss === 'string') {
const result = sanitizeBrandingCss(brandingCss);
sanitizedBrandingCss = result.css.trim() === '' ? null : result.css;
cssWarnings = result.warnings;
}
// Strip empty-string colour values; collapse to `null` when the payload
// contains no overrides. For teams this matters because brandingEnabled
// = null inherits from the org — leaving `{}` here would persist a real
// override of nothing once a team toggles brandingEnabled = true.
const normalizedBrandingColors = normalizeBrandingColors(brandingColors);
await prisma.team.update({
where: {
id: teamId,
@@ -154,6 +179,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
brandingCss: sanitizedBrandingCss,
// Email related settings.
emailId,
@@ -168,4 +195,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
},
},
});
return {
cssWarnings: cssWarnings && cssWarnings.length > 0 ? cssWarnings : undefined,
};
});
@@ -1,10 +1,13 @@
import { BRANDING_CSS_MAX_LENGTH } from '@documenso/lib/constants/branding';
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZDocumentMetaDateFormatSchema, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { ZSanitizeBrandingCssWarningSchema } from '@documenso/lib/utils/sanitize-branding-css';
import { zEmail } from '@documenso/lib/utils/zod';
import { z } from 'zod';
@@ -35,6 +38,8 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
brandingLogo: z.string().nullish(),
brandingUrl: z.string().nullish(),
brandingCompanyDetails: z.string().nullish(),
brandingColors: ZCssVarsSchema.nullish(),
brandingCss: z.string().max(BRANDING_CSS_MAX_LENGTH).nullish(),
// Email related settings.
emailId: z.string().nullish(),
@@ -49,4 +54,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
}),
});
export const ZUpdateTeamSettingsResponseSchema = z.void();
export const ZUpdateTeamSettingsResponseSchema = z.object({
cssWarnings: z.array(ZSanitizeBrandingCssWarningSchema).optional(),
});