Files
documenso/packages/trpc/server/team-router/update-team-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

203 lines
6.8 KiB
TypeScript

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';
import { authenticatedProcedure } from '../trpc';
import { ZUpdateTeamSettingsRequestSchema, ZUpdateTeamSettingsResponseSchema } from './update-team-settings.types';
export const updateTeamSettingsRoute = authenticatedProcedure
.input(ZUpdateTeamSettingsRequestSchema)
.output(ZUpdateTeamSettingsResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { teamId, data } = input;
ctx.logger.info({
input: {
teamId,
},
});
const {
// Document related settings.
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod,
reminderSettings,
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
brandingColors,
brandingCss,
// Email related settings.
emailId,
emailReplyTo,
// emailReplyToName,
emailDocumentSettings,
// Default recipients settings.
defaultRecipients,
// AI features settings.
aiFeaturesEnabled,
} = data;
if (Object.values(data).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'No settings to update',
});
}
// Signatures will only be inherited if all are NULL.
if (typedSignatureEnabled === false && uploadSignatureEnabled === false && drawSignatureEnabled === false) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one signature type must be enabled',
});
}
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId: user.id,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this team.',
});
}
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId: team.organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
select: {
type: true,
organisationGlobalSettings: {
select: {
includeSenderDetails: true,
},
},
},
});
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 teams cannot update the sender details',
});
}
// 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,
},
data: {
teamGlobalSettings: {
update: {
// Document related settings.
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
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: emailDocumentSettings === null ? Prisma.DbNull : emailDocumentSettings,
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
// AI features settings.
aiFeaturesEnabled,
},
},
},
});
return {
cssWarnings: cssWarnings && cssWarnings.length > 0 ? cssWarnings : undefined,
};
});