mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +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,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>
|
||||
|
||||
Reference in New Issue
Block a user