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
@@ -2,6 +2,7 @@ import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
@@ -20,6 +21,8 @@ import { DocumentSigningAuthProvider } from '~/components/general/document-signi
import { DocumentSigningPageViewV2 } from '~/components/general/document-signing/document-signing-page-view-v2';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
import { RecipientBranding } from '~/components/general/recipient-branding';
import { useCspNonce } from '~/utils/nonce';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/_index';
@@ -125,6 +128,7 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
},
select: {
internalVersion: true,
teamId: true,
},
});
@@ -132,12 +136,17 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
throw new Response('Not Found', { status: 404 });
}
const branding = await loadRecipientBrandingByTeamId({
teamId: directEnvelope.teamId,
});
if (directEnvelope.internalVersion === 2) {
const payloadV2 = await handleV2Loader(loaderArgs);
return superLoaderJson({
version: 2,
payload: payloadV2,
branding,
} as const);
}
@@ -146,17 +155,20 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
return superLoaderJson({
version: 1,
payload: payloadV1,
branding,
} as const);
}
export default function DirectTemplatePage() {
const data = useSuperLoaderData<typeof loader>();
const cspNonce = useCspNonce();
if (data.version === 2) {
return <DirectSigningPageV2 data={data.payload} />;
}
return <DirectSigningPageV1 data={data.payload} />;
return (
<>
<RecipientBranding branding={data.branding} cspNonce={cspNonce} />
{data.version === 2 ? <DirectSigningPageV2 data={data.payload} /> : <DirectSigningPageV1 data={data.payload} />}
</>
);
}
const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {