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,7 +1,11 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
@@ -15,6 +19,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@@ -28,17 +33,20 @@ const ZBrandingPreferencesFormSchema = z.object({
.nullish(),
brandingUrl: z.string().url().optional().or(z.literal('')),
brandingCompanyDetails: z.string().max(500).optional(),
brandingColors: ZCssVarsSchema.default({}),
brandingCss: z.string().max(10_000).default(''),
});
export type TBrandingPreferencesFormSchema = z.infer<typeof ZBrandingPreferencesFormSchema>;
type SettingsSubset = Pick<
TeamGlobalSettings,
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails'
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails' | 'brandingColors' | 'brandingCss'
>;
export type BrandingPreferencesFormProps = {
canInherit?: boolean;
hasAdvancedBranding: boolean;
settings: SettingsSubset;
onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise<void>;
context: 'Team' | 'Organisation';
@@ -46,11 +54,13 @@ export type BrandingPreferencesFormProps = {
export function BrandingPreferencesForm({
canInherit = false,
hasAdvancedBranding,
settings,
onFormSubmit,
context,
}: BrandingPreferencesFormProps) {
const { t } = useLingui();
const nonce = useCspNonce();
const team = useOptionalCurrentTeam();
const organisation = useCurrentOrganisation();
@@ -58,12 +68,17 @@ export function BrandingPreferencesForm({
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
const initialColors = parsedColors.success ? parsedColors.data : {};
const form = useForm<TBrandingPreferencesFormSchema>({
defaultValues: {
values: {
brandingEnabled: settings.brandingEnabled ?? null,
brandingUrl: settings.brandingUrl ?? '',
brandingLogo: undefined,
brandingCompanyDetails: settings.brandingCompanyDetails ?? '',
brandingColors: initialColors,
brandingCss: settings.brandingCss ?? '',
},
resolver: zodResolver(ZBrandingPreferencesFormSchema),
});
@@ -304,6 +319,225 @@ export function BrandingPreferencesForm({
/>
</div>
{hasAdvancedBranding && (
<div className="relative flex w-full flex-col gap-y-6">
{!isBrandingEnabled && <div className="absolute inset-0 z-[9998] bg-background/60" />}
<div>
<FormLabel>
<Trans>Brand Colours</Trans>
</FormLabel>
<FormDescription className="mt-1 mb-4">
<Trans>Customise the colours used on your signing pages.</Trans>
</FormDescription>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="brandingColors.background"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background</Trans>
</FormLabel>
<FormDescription>
<Trans>Base background colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.background}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.foreground"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Foreground</Trans>
</FormLabel>
<FormDescription>
<Trans>Base text colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.foreground}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.primary"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Primary</Trans>
</FormLabel>
<FormDescription>
<Trans>Primary action colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primary}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.primaryForeground"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Primary Foreground</Trans>
</FormLabel>
<FormDescription>
<Trans>Text colour on primary buttons.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.border"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Border</Trans>
</FormLabel>
<FormDescription>
<Trans>Default border colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.border}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="brandingColors.ring"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Ring</Trans>
</FormLabel>
<FormDescription>
<Trans>Focus ring colour.</Trans>
</FormDescription>
<FormControl>
<ColorPicker
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.ring}
onChange={(color) => field.onChange(color)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="mt-4">
<FormField
control={form.control}
name="brandingColors.radius"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Border Radius</Trans>
</FormLabel>
<FormControl>
<Input
type="text"
placeholder={DEFAULT_BRAND_RADIUS}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
<FormDescription>
<Trans>Border radius size in REM units (e.g. 0.5rem).</Trans>
</FormDescription>
</FormItem>
)}
/>
</div>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="custom-css" className="border-none">
<AccordionTrigger className="rounded border px-3 py-2 text-left text-foreground hover:bg-muted/40 hover:no-underline">
<Trans>Advanced Custom CSS</Trans>
</AccordionTrigger>
<AccordionContent className="-mx-1 px-1 pt-4 text-muted-foreground text-sm leading-relaxed">
<FormField
control={form.control}
name="brandingCss"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Textarea
placeholder={t`/* Write CSS targeting your signing pages. Selectors are scoped automatically. */
.my-button {
background: red;
}`}
className="min-h-[200px] font-mono text-xs"
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormDescription>
<Trans>
Custom CSS is sanitised on save. Layout-breaking properties, remote URLs, and
pseudo-elements are stripped automatically. Any rules dropped during sanitisation will be
shown after you save.
</Trans>
</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>