mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 17:04:12 +10:00
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:
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user