Files
documenso/packages/trpc/server/organisation-router/update-organisation-settings.ts
T
Lucas Smith 0b86ece1d5 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.
2026-05-11 13:03:02 +10:00

200 lines
6.6 KiB
TypeScript

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';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationSettingsRequestSchema,
ZUpdateOrganisationSettingsResponseSchema,
} from './update-organisation-settings.types';
export const updateOrganisationSettingsRoute = authenticatedProcedure
.input(ZUpdateOrganisationSettingsRequestSchema)
.output(ZUpdateOrganisationSettingsResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { organisationId, data } = input;
ctx.logger.info({
input: {
organisationId,
},
});
const {
// Document related settings.
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
defaultRecipients,
delegateDocumentOwnership,
envelopeExpirationPeriod,
reminderSettings,
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
brandingCss,
// Email related settings.
emailId,
emailReplyTo,
// emailReplyToName,
emailDocumentSettings,
// AI features settings.
aiFeaturesEnabled,
} = data;
if (Object.values(data).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'No settings to update',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationGlobalSettings: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this organisation.',
});
}
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
const derivedTypedSignatureEnabled =
typedSignatureEnabled ?? organisation.organisationGlobalSettings.typedSignatureEnabled;
const derivedUploadSignatureEnabled =
uploadSignatureEnabled ?? organisation.organisationGlobalSettings.uploadSignatureEnabled;
const derivedDrawSignatureEnabled =
drawSignatureEnabled ?? organisation.organisationGlobalSettings.drawSignatureEnabled;
const derivedDelegateDocumentOwnership =
delegateDocumentOwnership ?? organisation.organisationGlobalSettings.delegateDocumentOwnership;
if (
derivedTypedSignatureEnabled === false &&
derivedUploadSignatureEnabled === false &&
derivedDrawSignatureEnabled === false
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one signature type must be enabled',
});
}
const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
const currentIncludeSenderDetails = organisation.organisationGlobalSettings.includeSenderDetails;
const isChangingIncludeSenderDetails =
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Personal organisations cannot update the sender details',
});
}
// 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,
},
data: {
organisationGlobalSettings: {
update: {
// Document related settings.
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
brandingCss: sanitizedBrandingCss,
// Email related settings.
emailId,
emailReplyTo,
// emailReplyToName,
emailDocumentSettings,
// AI features settings.
aiFeaturesEnabled,
},
},
},
});
return {
cssWarnings: cssWarnings && cssWarnings.length > 0 ? cssWarnings : undefined,
};
});