mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +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:
@@ -0,0 +1,138 @@
|
||||
---
|
||||
date: 2026-05-06
|
||||
title: Platform Signing Page Branding
|
||||
---
|
||||
|
||||
## What
|
||||
|
||||
Platform-plan organisations (and their teams) can customise the **non-embed
|
||||
signing pages** (`/sign/:token`, `/d/:token`, and the sibling
|
||||
complete/expired/rejected/waiting pages) with:
|
||||
|
||||
- Six brand colour tokens (background, foreground, primary, primary-foreground,
|
||||
border, ring) plus a border-radius length.
|
||||
- A free-text custom CSS block (up to 256 KB).
|
||||
|
||||
Settings live on `OrganisationGlobalSettings` and `TeamGlobalSettings`. Teams
|
||||
inherit from the org via the existing `brandingEnabled === null` mechanism.
|
||||
|
||||
## Why
|
||||
|
||||
- Embed customers already have white-label CSS; Platform customers want the
|
||||
same coverage on direct signing URLs that they iframe or link to.
|
||||
- Persisting on org/team (not per envelope) means it's set-and-forget.
|
||||
- Sanitising **on save** lets us inline the verbatim string at SSR — no
|
||||
per-render parsing cost, no `<style>.innerHTML` injection on the client.
|
||||
- Reusing the existing `embedSigningWhiteLabel` claim flag keeps "if you can
|
||||
white-label an embed, you can white-label this" as one decision.
|
||||
|
||||
## How
|
||||
|
||||
### Storage (`packages/prisma/schema.prisma`)
|
||||
|
||||
Two new fields on each settings model. No new tables.
|
||||
|
||||
| Field | Org type | Team type |
|
||||
| ---------------- | ------------------ | ------------------ |
|
||||
| `brandingColors` | `Json?` (nullable) | `Json?` (nullable) |
|
||||
| `brandingCss` | `String @default("")` | `String?` |
|
||||
|
||||
Colours are validated against `ZCssVarsSchema`. The team's `null` means
|
||||
"inherit"; an empty colour object is collapsed to `null` server-side so a
|
||||
team toggling `brandingEnabled = true` without filling in colours doesn't
|
||||
silently override the org's defaults with nothing.
|
||||
|
||||
### Sanitiser (`packages/lib/utils/sanitize-branding-css.ts`)
|
||||
|
||||
PostCSS + `postcss-selector-parser`. Runs on save only.
|
||||
|
||||
- Drops selectors containing `::before`/`::after`/`::backdrop`/`::marker` or
|
||||
the universal `*`.
|
||||
- Drops integrity-breaking properties (`display`, `position`, `transform`,
|
||||
layout-affecting dimensions, text-hiding properties).
|
||||
- Drops declaration values containing `url(`, `expression(`, `@import`,
|
||||
`javascript:`.
|
||||
- Strips `!important`.
|
||||
- Allows `@media` only; drops other at-rules.
|
||||
- **Does not** rewrite selectors. Scoping happens at render time via native
|
||||
CSS nesting under `.documenso-branded { ... }`.
|
||||
- Final-pass tripwire: if a literal `</style` somehow survives serialization,
|
||||
reject the entire output. PostCSS already escapes `<` to `\3c` whenever it
|
||||
would form `</...`; the explicit check is belt-and-braces in case a future
|
||||
serializer regresses.
|
||||
- Returns `{ css, warnings[] }`. Warnings are surfaced in the UI.
|
||||
|
||||
Border-radius is the only token interpolated raw into a `<style>` block; it
|
||||
is regex-validated (`CSS_LENGTH_REGEX`) at both the Zod schema and the
|
||||
runtime `toNativeCssVars` call. Belt-and-braces against schema drift.
|
||||
|
||||
### Render (`apps/remix/app/components/general/recipient-branding.tsx`)
|
||||
|
||||
Each recipient loader calls `loadRecipientBrandingByTeamId` and threads the
|
||||
payload through to `<RecipientBranding>`, which emits a single
|
||||
nonce-attributed `<style>`:
|
||||
|
||||
```
|
||||
.documenso-branded {
|
||||
--background: ...; ...
|
||||
<user css>
|
||||
}
|
||||
```
|
||||
|
||||
Native CSS nesting expands user rules under the wrapper. The body class is
|
||||
applied unconditionally to recipient routes in `root.tsx` via `useMatches()`
|
||||
so portaled Radix content (dialogs, popovers, tooltips, dropdowns) inherits
|
||||
the scope.
|
||||
|
||||
CSP for recipient routes already supports `<style nonce>`; no policy
|
||||
changes needed.
|
||||
|
||||
### Plan gate
|
||||
|
||||
`organisationClaim.flags.embedSigningWhiteLabel || !IS_BILLING_ENABLED()`.
|
||||
Self-hosted instances always allow. The outer paywall for logo/URL/details
|
||||
stays on `allowCustomBranding` (Team plan and up); only the new
|
||||
colour/CSS section is Platform-only.
|
||||
|
||||
### UI (`apps/remix/app/components/forms/branding-preferences-form.tsx`)
|
||||
|
||||
Extends the existing branding form. Six `<ColorPicker showHex>` (rewritten
|
||||
to use the native `<input type="color">` instead of `react-colorful`, which
|
||||
was removed) in a 2-col grid, plus a free-text radius input and an
|
||||
`<Accordion>` revealing a mono `<Textarea>`. Defaults come from
|
||||
`packages/lib/constants/theme.ts` (light-mode hex mirror of `theme.css`).
|
||||
|
||||
Warnings from the sanitiser are surfaced in an `<Alert variant="warning">`
|
||||
after save, and the `brandingCss` textarea is re-synced from the persisted
|
||||
value so the user sees exactly what was stored. Other fields are
|
||||
deliberately NOT reset on settings refetch — that would clobber in-flight
|
||||
edits.
|
||||
|
||||
### TRPC
|
||||
|
||||
`update-organisation-settings` and `update-team-settings` accept the new
|
||||
fields, run them through `sanitizeBrandingCss` + `normalizeBrandingColors`,
|
||||
and return any sanitiser warnings to the client. The team route treats
|
||||
`null` as "inherit"; an empty post-sanitisation string is collapsed to
|
||||
`null` (team) so an empty override doesn't mask the org's CSS.
|
||||
|
||||
## Known accepted limitations
|
||||
|
||||
- The sanitiser does not prevent hostile-but-syntactically-valid CSS
|
||||
(`color: transparent`, low-contrast values, etc.). The customer is
|
||||
branding **their own** signing pages — we focus on integrity (no
|
||||
overlay/hide/exfiltrate), not aesthetic policing.
|
||||
- User rules targeting `body`/`html`/`:root` no-op once nested under the
|
||||
wrapper class. Documented for users.
|
||||
- CSS nesting baseline is Chrome 120+ / Firefox 117+ / Safari 16.5+.
|
||||
Acceptable for the Platform-tier audience.
|
||||
- No automated `theme.css` ↔ `theme.ts` sync check; fat comment in
|
||||
`theme.ts` reminds devs to update both.
|
||||
- Per-section team inherit is coarse — `brandingEnabled = null` inherits
|
||||
everything from the org. Per-field inherit toggles are deferred.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Live preview, embed-route sanitiser unification, email/PDF certificate
|
||||
branding, custom font upload, the full ~30 colour tokens in the picker UI,
|
||||
wiring `hidePoweredBy` through to the actual footer.
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { TCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { toNativeCssVarsString } from '~/utils/css-vars';
|
||||
|
||||
export type RecipientBrandingPayload = {
|
||||
allowCustomBranding: boolean;
|
||||
colors?: TCssVarsSchema | null;
|
||||
css?: string | null;
|
||||
};
|
||||
|
||||
export type RecipientBrandingProps = {
|
||||
branding: RecipientBrandingPayload | null | undefined;
|
||||
cspNonce: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a `<style nonce>` block for a recipient route, scoped to the
|
||||
* `.documenso-branded` wrapper rendered in `_recipient+/_layout.tsx`.
|
||||
*
|
||||
* Both the CSS variables (from `branding.colors`) and the user's custom CSS
|
||||
* (from `branding.css`) are emitted inside a single nested rule so the user
|
||||
* doesn't need to scope their own selectors — native CSS nesting handles it:
|
||||
*
|
||||
* .documenso-branded {
|
||||
* --background: ...;
|
||||
* .my-class { color: red; }
|
||||
* }
|
||||
*
|
||||
* Equivalent to `.documenso-branded .my-class { color: red; }` after expansion.
|
||||
*
|
||||
* The user's CSS is sanitised at write time (`sanitizeBrandingCss`) and stored
|
||||
* in the DB as-is — no per-render parsing.
|
||||
*
|
||||
* Why both SSR `<style>` and a `useEffect` injection?
|
||||
*
|
||||
* The rendered `<style>` covers the initial server render so the first paint
|
||||
* already has the branding applied — without it, the page would flash the
|
||||
* default theme before hydration.
|
||||
*
|
||||
* The `useEffect` covers in-app client-side navigations. When the user
|
||||
* navigates between recipient routes via the router, the server render
|
||||
* doesn't run again, so React reconciles the existing DOM. If the loader
|
||||
* data changes (e.g. a different recipient with different branding), the
|
||||
* SSR'd `<style>` from the previous page may persist or be reused, leading
|
||||
* to stale or inconsistent branding. Appending a fresh `<style>` to
|
||||
* `document.head` and removing it on cleanup guarantees the active branding
|
||||
* matches the current route on both initial load and subsequent navigations.
|
||||
*/
|
||||
export const RecipientBranding = ({ branding, cspNonce }: RecipientBrandingProps) => {
|
||||
const varsString = toNativeCssVarsString(branding?.colors ?? {});
|
||||
|
||||
const userCss = branding?.css ?? '';
|
||||
|
||||
const hasVars = varsString.trim().length > 0;
|
||||
const hasUserCss = userCss.trim().length > 0;
|
||||
|
||||
const innerBody = `${hasVars ? `${varsString}\n` : ''}${hasUserCss ? userCss : ''}`.trim();
|
||||
const css = `.documenso-branded { ${innerBody} }`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!branding?.allowCustomBranding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasVars && !hasUserCss) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('nonce', cspNonce ?? '');
|
||||
style.textContent = css;
|
||||
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, [branding, cspNonce, css, hasUserCss, hasVars]);
|
||||
|
||||
if (!branding?.allowCustomBranding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasVars && !hasUserCss) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <style nonce={cspNonce}>{css}</style>;
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useLoaderData,
|
||||
useMatches,
|
||||
} from 'react-router';
|
||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
||||
|
||||
@@ -110,6 +111,13 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const [theme] = useTheme();
|
||||
|
||||
// Recipient routes (signing pages) put `documenso-branded` on <body> so the
|
||||
// <style> block from `RecipientBranding` applies to BOTH the main tree and
|
||||
// any portaled content (Radix dialogs/popovers/dropdowns mount outside the
|
||||
// route tree, attached directly to document.body).
|
||||
const matches = useMatches();
|
||||
const isRecipientRoute = matches.some((m) => m.id?.startsWith('routes/_recipient+'));
|
||||
|
||||
return (
|
||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
|
||||
<head>
|
||||
@@ -137,7 +145,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
||||
<script nonce={nonce(cspNonce)}>0</script>
|
||||
</head>
|
||||
<body>
|
||||
<body className={isRecipientRoute ? 'documenso-branded' : undefined}>
|
||||
{/* Global license banner currently disabled. Need to wait until after a few releases. */}
|
||||
{/* {licenseStatus === '?' && (
|
||||
<div className="bg-destructive text-destructive-foreground">
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useRevalidator } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import type { Route } from './+types/site-settings';
|
||||
|
||||
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
||||
@@ -45,6 +45,8 @@ export async function loader() {
|
||||
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
||||
const { banner } = loaderData;
|
||||
|
||||
const nonce = useCspNonce();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { revalidate } = useRevalidator();
|
||||
@@ -142,7 +144,7 @@ export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
<ColorPicker {...field} nonce={nonce} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
@@ -162,7 +164,7 @@ export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
<ColorPicker {...field} nonce={nonce} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { msg, plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import {
|
||||
@@ -35,7 +37,13 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } = trpc.organisation.get.useQuery({
|
||||
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
|
||||
|
||||
const {
|
||||
data: organisationWithSettings,
|
||||
isLoading: isLoadingOrganisation,
|
||||
refetch: refetchOrganisation,
|
||||
} = trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
|
||||
@@ -43,7 +51,7 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
|
||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||
|
||||
let uploadedBrandingLogo: string | undefined;
|
||||
|
||||
@@ -56,20 +64,40 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateOrganisationSettings({
|
||||
const result = await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
brandingEnabled: brandingEnabled ?? undefined,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors,
|
||||
brandingCss,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
// Refetch so the form re-syncs with the sanitised CSS that was
|
||||
// actually persisted (sanitiser may have dropped rules).
|
||||
await refetchOrganisation();
|
||||
|
||||
const warnings = result?.cssWarnings ?? [];
|
||||
setCssWarnings(warnings);
|
||||
|
||||
if (warnings.length > 0) {
|
||||
toast({
|
||||
title: t`Branding preferences updated with warnings`,
|
||||
description: plural(warnings.length, {
|
||||
one: '# CSS rule was dropped during sanitisation.',
|
||||
other: '# CSS rules were dropped during sanitisation.',
|
||||
}),
|
||||
duration: 8000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
@@ -103,9 +131,36 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
context="Organisation"
|
||||
hasAdvancedBranding={
|
||||
organisationWithSettings.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED()
|
||||
}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
{cssWarnings.length > 0 && (
|
||||
<Alert variant="warning" className="mt-6">
|
||||
<AlertTitle>
|
||||
<Trans>CSS rules were dropped during sanitisation</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-5">
|
||||
{cssWarnings.map((warning, index) => (
|
||||
<li key={index}>
|
||||
{warning.detail}
|
||||
{warning.line !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
<Trans>(line {warning.line})</Trans>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<Alert className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import type { SanitizeBrandingCssWarning } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
BrandingPreferencesForm,
|
||||
@@ -11,27 +16,32 @@ import {
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Branding Preferences`);
|
||||
}
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
|
||||
const [cssWarnings, setCssWarnings] = useState<SanitizeBrandingCssWarning[]>([]);
|
||||
|
||||
const {
|
||||
data: teamWithSettings,
|
||||
isLoading: isLoadingTeam,
|
||||
refetch: refetchTeam,
|
||||
} = trpc.team.get.useQuery({
|
||||
teamReference: team.id,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||
|
||||
const canCustomBranding =
|
||||
organisation.organisationClaim.flags.embedSigningWhiteLabel === true || !IS_BILLING_ENABLED();
|
||||
|
||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails, brandingColors, brandingCss } = data;
|
||||
|
||||
let uploadedBrandingLogo: string | undefined;
|
||||
|
||||
@@ -44,20 +54,40 @@ export default function TeamsSettingsPage() {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateTeamSettings({
|
||||
const result = await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl: brandingUrl || null,
|
||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||
brandingColors,
|
||||
brandingCss,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
// Refetch so the form re-syncs with the sanitised CSS that was
|
||||
// actually persisted (sanitiser may have dropped rules).
|
||||
await refetchTeam();
|
||||
|
||||
const warnings = result?.cssWarnings ?? [];
|
||||
setCssWarnings(warnings);
|
||||
|
||||
if (warnings.length > 0) {
|
||||
toast({
|
||||
title: t`Branding preferences updated with warnings`,
|
||||
description: plural(warnings.length, {
|
||||
one: '# CSS rule was dropped during sanitisation.',
|
||||
other: '# CSS rules were dropped during sanitisation.',
|
||||
}),
|
||||
duration: 8000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
@@ -85,10 +115,35 @@ export default function TeamsSettingsPage() {
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
canInherit={true}
|
||||
hasAdvancedBranding={canCustomBranding}
|
||||
context="Team"
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||
/>
|
||||
|
||||
{cssWarnings.length > 0 && (
|
||||
<Alert variant="warning" className="mt-6">
|
||||
<AlertTitle>
|
||||
<Trans>CSS rules were dropped during sanitisation</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-5">
|
||||
{cssWarnings.map((warning, index) => (
|
||||
<li key={index}>
|
||||
{warning.detail}
|
||||
{warning.line !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
<Trans>(line {warning.line})</Trans>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>> }) => {
|
||||
|
||||
@@ -3,6 +3,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 { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||
import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||
@@ -35,6 +36,8 @@ import { DocumentSigningPageViewV1 } from '~/components/general/document-signing
|
||||
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';
|
||||
@@ -272,6 +275,7 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
envelope: {
|
||||
select: {
|
||||
internalVersion: true,
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -281,12 +285,17 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({
|
||||
teamId: foundRecipient.envelope.teamId,
|
||||
});
|
||||
|
||||
if (foundRecipient.envelope.internalVersion === 2) {
|
||||
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||
|
||||
return superLoaderJson({
|
||||
version: 2,
|
||||
payload: payloadV2,
|
||||
branding,
|
||||
} as const);
|
||||
}
|
||||
|
||||
@@ -295,17 +304,20 @@ export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||
return superLoaderJson({
|
||||
version: 1,
|
||||
payload: payloadV1,
|
||||
branding,
|
||||
} as const);
|
||||
}
|
||||
|
||||
export default function SigningPage() {
|
||||
const data = useSuperLoaderData<typeof loader>();
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
if (data.version === 2) {
|
||||
return <SigningPageV2 data={data.payload} />;
|
||||
}
|
||||
|
||||
return <SigningPageV1 data={data.payload} />;
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={data.branding} cspNonce={cspNonce} />
|
||||
{data.version === 2 ? <SigningPageV2 data={data.payload} /> : <SigningPageV1 data={data.payload} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loader>> }) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import signingCelebration from '@documenso/assets/images/signing-celebration.png
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
@@ -25,6 +26,8 @@ import { match } from 'ts-pattern';
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { ClaimAccount } from '~/components/general/claim-account';
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
import type { Route } from './+types/complete';
|
||||
|
||||
@@ -46,6 +49,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
@@ -66,6 +71,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientEmail: recipient.email,
|
||||
branding,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -92,6 +98,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
document,
|
||||
recipient,
|
||||
returnToHomePath,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +107,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const {
|
||||
isDocumentAccessValid,
|
||||
@@ -110,6 +118,7 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
recipient,
|
||||
recipientEmail,
|
||||
returnToHomePath,
|
||||
branding,
|
||||
} = loaderData;
|
||||
|
||||
// Poll signing status every few seconds
|
||||
@@ -131,154 +140,163 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
||||
const signingStatus = signingStatusData?.status ?? 'PENDING';
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientEmail} />;
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<DocumentSigningAuthPageView email={recipientEmail} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28', {
|
||||
'pt-0 lg:pt-0 xl:pt-0': canSignUp,
|
||||
})}
|
||||
>
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<div
|
||||
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
|
||||
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
|
||||
canSignUp,
|
||||
})}
|
||||
className={cn(
|
||||
'-mx-4 flex flex-col items-center overflow-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-20 xl:pt-28',
|
||||
{ 'pt-0 lg:pt-0 xl:pt-0': canSignUp },
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('flex flex-col items-center', {
|
||||
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
|
||||
className={cn('relative mt-6 flex w-full flex-col items-center justify-center', {
|
||||
'mt-0 flex-col divide-y overflow-hidden pt-6 md:pt-16 lg:flex-row lg:divide-x lg:divide-y-0 lg:pt-20 xl:pt-24':
|
||||
canSignUp,
|
||||
})}
|
||||
>
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||
{document.title}
|
||||
</span>
|
||||
</Badge>
|
||||
<div
|
||||
className={cn('flex flex-col items-center', {
|
||||
'mb-8 p-4 md:mb-0 md:p-12': canSignUp,
|
||||
})}
|
||||
>
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
||||
{document.title}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
{recipient.role === RecipientRole.SIGNER && <Trans>Document Signed</Trans>}
|
||||
{recipient.role === RecipientRole.VIEWER && <Trans>Document Viewed</Trans>}
|
||||
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
|
||||
</h2>
|
||||
|
||||
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||
.with({ status: 'COMPLETED' }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-documenso-700">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Everyone has signed</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.with({ status: 'PROCESSING' }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-orange-600">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
<span className="text-sm">
|
||||
<Trans>Processing document</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Waiting for others to sign</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Document no longer available to sign</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||
.with({ status: 'COMPLETED' }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>Everyone has signed! You will receive an email copy of the signed document.</Trans>
|
||||
</p>
|
||||
))
|
||||
.with({ status: 'PROCESSING' }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>
|
||||
All recipients have signed. The document is being processed and you will receive an email copy
|
||||
shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>You will receive an email copy of the signed document once everyone has signed.</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>
|
||||
This document has been cancelled by the owner and is no longer available for others to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
token={recipient.token}
|
||||
className="w-full max-w-none md:flex-1"
|
||||
{/* Card with recipient */}
|
||||
<SigningCard3D
|
||||
name={recipientName}
|
||||
signature={signatures.at(0)}
|
||||
signingCelebrationImage={signingCelebration}
|
||||
/>
|
||||
|
||||
{isDocumentCompleted(document) && (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={document.envelopeId}
|
||||
envelopeStatus={document.status}
|
||||
envelopeItems={document.envelopeItems}
|
||||
token={recipient?.token}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<h2 className="mt-6 max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
{recipient.role === RecipientRole.SIGNER && <Trans>Document Signed</Trans>}
|
||||
{recipient.role === RecipientRole.VIEWER && <Trans>Document Viewed</Trans>}
|
||||
{recipient.role === RecipientRole.APPROVER && <Trans>Document Approved</Trans>}
|
||||
</h2>
|
||||
|
||||
{user && (
|
||||
<Button asChild>
|
||||
<Link to={returnToHomePath}>
|
||||
<Trans>Go Back Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||
.with({ status: 'COMPLETED' }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-documenso-700">
|
||||
<CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Everyone has signed</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.with({ status: 'PROCESSING' }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-orange-600">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
<span className="text-sm">
|
||||
<Trans>Processing document</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<div className="mt-4 flex items-center text-center text-blue-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Waiting for others to sign</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<div className="flex items-center text-center text-red-600">
|
||||
<Clock8 className="mr-2 h-5 w-5" />
|
||||
<span className="text-sm">
|
||||
<Trans>Document no longer available to sign</Trans>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{match({ status: signingStatus, deletedAt: document.deletedAt })
|
||||
.with({ status: 'COMPLETED' }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>Everyone has signed! You will receive an email copy of the signed document.</Trans>
|
||||
</p>
|
||||
))
|
||||
.with({ status: 'PROCESSING' }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>
|
||||
All recipients have signed. The document is being processed and you will receive an email copy
|
||||
shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
))
|
||||
.with({ deletedAt: null }, () => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>You will receive an email copy of the signed document once everyone has signed.</Trans>
|
||||
</p>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<p className="mt-2.5 max-w-[60ch] text-center font-medium text-muted-foreground/60 text-sm md:text-base">
|
||||
<Trans>
|
||||
This document has been cancelled by the owner and is no longer available for others to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
))}
|
||||
|
||||
<div className="mt-8 flex w-full max-w-xs flex-col items-stretch gap-4 md:w-auto md:max-w-none md:flex-row md:items-center">
|
||||
<DocumentShareButton
|
||||
documentId={document.id}
|
||||
token={recipient.token}
|
||||
className="w-full max-w-none md:flex-1"
|
||||
/>
|
||||
|
||||
{isDocumentCompleted(document) && (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={document.envelopeId}
|
||||
envelopeStatus={document.status}
|
||||
envelopeItems={document.envelopeItems}
|
||||
token={recipient?.token}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" className="flex-1 md:flex-initial">
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<Button asChild>
|
||||
<Link to={returnToHomePath}>
|
||||
<Trans>Go Back Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
{canSignUp && (
|
||||
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
|
||||
<h2 className="mt-8 text-center font-semibold text-xl md:mt-0">
|
||||
<Trans>Need to sign documents?</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 max-w-[55ch] text-center text-muted-foreground/60 leading-normal">
|
||||
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
|
||||
</p>
|
||||
|
||||
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
{canSignUp && (
|
||||
<div className="flex max-w-xl flex-col items-center justify-center p-4 md:p-12">
|
||||
<h2 className="mt-8 text-center font-semibold text-xl md:mt-0">
|
||||
<Trans>Need to sign documents?</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 max-w-[55ch] text-center text-muted-foreground/60 leading-normal">
|
||||
<Trans>Create your account and start using state-of-the-art document signing.</Trans>
|
||||
</p>
|
||||
|
||||
<ClaimAccount defaultName={recipientName} defaultEmail={recipient.email} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
@@ -10,6 +11,8 @@ import { TimerOffIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import { truncateTitle } from '~/utils/truncate-title';
|
||||
|
||||
import type { Route } from './+types/expired';
|
||||
@@ -32,6 +35,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
|
||||
|
||||
const title = document.title;
|
||||
|
||||
const recipient = await getRecipientByToken({ token }).catch(() => null);
|
||||
@@ -54,55 +59,66 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
isDocumentAccessValid: true,
|
||||
recipientEmail,
|
||||
title,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientEmail,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExpiredSigningPage({ loaderData }: Route.ComponentProps) {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const { isDocumentAccessValid, recipientEmail, title } = loaderData;
|
||||
const { isDocumentAccessValid, recipientEmail, title, branding } = loaderData;
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientEmail} />;
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<DocumentSigningAuthPageView email={recipientEmail} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" title={title} className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncateTitle(title ?? '')}
|
||||
</Badge>
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" title={title} className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncateTitle(title ?? '')}
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<TimerOffIcon className="h-10 w-10 text-orange-500" />
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<TimerOffIcon className="h-10 w-10 text-orange-500" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing Deadline Expired</Trans>
|
||||
</h2>
|
||||
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing Deadline Expired</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The signing deadline for this document has passed. Please contact the document owner if you need a new
|
||||
copy to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The signing deadline for this document has passed. Please contact the document owner if you need a new copy
|
||||
to sign.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
@@ -12,6 +13,8 @@ import { XCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
import { truncateTitle } from '~/utils/truncate-title';
|
||||
|
||||
import type { Route } from './+types/rejected';
|
||||
@@ -34,6 +37,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
@@ -60,6 +65,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
isDocumentAccessValid: true,
|
||||
recipientReference,
|
||||
truncatedTitle,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,57 +73,67 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientReference,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
export default function RejectedSigningPage({ loaderData }: Route.ComponentProps) {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
const { isDocumentAccessValid, recipientReference, truncatedTitle } = loaderData;
|
||||
const { isDocumentAccessValid, recipientReference, truncatedTitle, branding } = loaderData;
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientReference} />;
|
||||
return (
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<DocumentSigningAuthPageView email={recipientReference} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<XCircle className="h-10 w-10 text-destructive" />
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<XCircle className="h-10 w-10 text-destructive" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Document Rejected</Trans>
|
||||
</h2>
|
||||
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Document Rejected</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center text-center text-destructive text-sm">
|
||||
<Trans>You have rejected this document</Trans>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The document owner has been notified of your decision. They may contact you with further instructions if
|
||||
necessary.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>No further action is required from you at this time.</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center text-center text-destructive text-sm">
|
||||
<Trans>You have rejected this document</Trans>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
The document owner has been notified of your decision. They may contact you with further instructions if
|
||||
necessary.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
|
||||
<Trans>No further action is required from you at this time.</Trans>
|
||||
</p>
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { loadRecipientBrandingByTeamId } from '@documenso/lib/server-only/branding/load-recipient-branding';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
@@ -10,6 +11,9 @@ import type { Team } from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { RecipientBranding } from '~/components/general/recipient-branding';
|
||||
import { useCspNonce } from '~/utils/nonce';
|
||||
|
||||
import type { Route } from './+types/waiting';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
@@ -61,48 +65,55 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
||||
const documentPathForEditing = isOwnerOrTeamMember && team ? formatDocumentsPath(team.url) + '/' + document.id : null;
|
||||
|
||||
const branding = await loadRecipientBrandingByTeamId({ teamId: document.teamId });
|
||||
|
||||
return {
|
||||
documentPathForEditing,
|
||||
branding,
|
||||
};
|
||||
}
|
||||
|
||||
export default function WaitingForTurnToSignPage({ loaderData }: Route.ComponentProps) {
|
||||
const { documentPathForEditing } = loaderData;
|
||||
const { documentPathForEditing, branding } = loaderData;
|
||||
const cspNonce = useCspNonce();
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h2 className="font-bold text-3xl tracking-tigh">
|
||||
<Trans>Waiting for Your Turn</Trans>
|
||||
</h2>
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
<div className="relative flex flex-col items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<h2 className="font-bold text-3xl tracking-tigh">
|
||||
<Trans>Waiting for Your Turn</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
It's currently not your turn to sign. You will receive an email with instructions once it's your turn to
|
||||
sign the document.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
It's currently not your turn to sign. You will receive an email with instructions once it's your turn to
|
||||
sign the document.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-muted-foreground text-sm">
|
||||
<Trans>Please check your email for updates.</Trans>
|
||||
</p>
|
||||
<p className="mt-4 text-muted-foreground text-sm">
|
||||
<Trans>Please check your email for updates.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
{documentPathForEditing ? (
|
||||
<Button variant="link" asChild>
|
||||
<Link to={documentPathForEditing}>
|
||||
<Trans>Were you trying to edit this document instead?</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="link" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
{documentPathForEditing ? (
|
||||
<Button variant="link" asChild>
|
||||
<Link to={documentPathForEditing}>
|
||||
<Trans>Were you trying to edit this document instead?</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="link" asChild>
|
||||
<Link to="/">
|
||||
<Trans>Return Home</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { CSS_LENGTH_REGEX, type TCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { colord } from 'colord';
|
||||
import { toKebabCase } from 'remeda';
|
||||
|
||||
@@ -12,23 +12,50 @@ export const toNativeCssVars = (vars: TCssVarsSchema) => {
|
||||
const color = colord(value);
|
||||
const { h, s, l } = color.toHsl();
|
||||
|
||||
cssVars[`--${toKebabCase(key)}`] = `${h} ${s} ${l}`;
|
||||
// Tailwind's theme.css consumes these via `hsl(var(--token))`. CSS
|
||||
// Color 4 space-separated `hsl()` requires `%` on saturation and
|
||||
// lightness — without it, the function is invalid and the property
|
||||
// falls back to its initial value (which is why bare numeric output
|
||||
// here used to silently break customer colours).
|
||||
cssVars[`--${toKebabCase(key)}`] = `${h} ${s}% ${l}%`;
|
||||
}
|
||||
}
|
||||
|
||||
if (radius) {
|
||||
cssVars[`--radius`] = `${radius}`;
|
||||
// Defence in depth: radius is interpolated raw into the rendered <style>
|
||||
// block, so anything outside the length pattern is a CSS-injection vector.
|
||||
// The Zod schema rejects bad values at the API boundary; this re-check
|
||||
// protects against schema drift and any path that bypasses validation.
|
||||
if (radius && CSS_LENGTH_REGEX.test(radius)) {
|
||||
cssVars[`--radius`] = radius;
|
||||
}
|
||||
|
||||
return cssVars;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure-string sibling of `toNativeCssVars` — returns the same set of CSS custom
|
||||
* property declarations as a single string suitable for SSR inlining inside a
|
||||
* rule block. Does not touch the DOM.
|
||||
*
|
||||
* Example: { background: '#111', radius: '0.5rem' }
|
||||
* -> "--background: 0 0% 6.7%; --radius: 0.5rem;"
|
||||
*
|
||||
* Saturation and lightness include the `%` suffix that
|
||||
* `hsl(var(--token))` requires under CSS Color 4 space-separated syntax.
|
||||
*/
|
||||
export const toNativeCssVarsString = (vars: TCssVarsSchema): string => {
|
||||
const map = toNativeCssVars(vars);
|
||||
return Object.entries(map)
|
||||
.map(([k, v]) => `${k}: ${v};`)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
export const injectCss = (options: { css?: string; cssVars?: TCssVarsSchema }) => {
|
||||
const { css, cssVars } = options;
|
||||
|
||||
if (css) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = css;
|
||||
style.textContent = css;
|
||||
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useRouteLoaderData } from 'react-router';
|
||||
|
||||
/**
|
||||
* Returns the supplied CSP nonce only when rendering on the server.
|
||||
*
|
||||
@@ -19,3 +21,18 @@
|
||||
* scripts inherit trust via `'strict-dynamic'`.
|
||||
*/
|
||||
export const nonce = (value: string | undefined): string | undefined => (typeof window === 'undefined' ? value : '');
|
||||
|
||||
/**
|
||||
* Reads the per-request CSP nonce surfaced by the root loader. Use this
|
||||
* inside any non-root route component that needs to render a `<style>`,
|
||||
* `<script>`, or other element that the CSP gates by nonce.
|
||||
*
|
||||
* Centralised here so the cast is in one place — if the root loader's
|
||||
* `nonce` field is ever renamed/removed, only this function needs updating
|
||||
* (and TypeScript will catch it at the cast site).
|
||||
*/
|
||||
export const useCspNonce = (): string | undefined => {
|
||||
const rootData = useRouteLoaderData('root') as { nonce?: string } | undefined;
|
||||
|
||||
return rootData?.nonce;
|
||||
};
|
||||
|
||||
Generated
+15
-14
@@ -349,20 +349,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"apps/docs/node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"apps/docs/node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
@@ -30684,6 +30670,8 @@
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
@@ -30701,6 +30689,19 @@
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"packages/lib/node_modules/postcss-selector-parser": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"packages/prettier-config": {
|
||||
"name": "@documenso/prettier-config",
|
||||
"version": "0.0.0",
|
||||
|
||||
@@ -32,10 +32,14 @@ const TEST_RAW_CSS = '.e2e-css-test-marker { color: red; }';
|
||||
* Expected HSL values after conversion by `toNativeCssVars`:
|
||||
* - colord('#ff0000').toHsl() → { h: 0, s: 100, l: 50 }
|
||||
* - colord('#00ff00').toHsl() → { h: 120, s: 100, l: 50 }
|
||||
*
|
||||
* The `%` on saturation and lightness is required: theme.css consumes these
|
||||
* via `hsl(var(--token))`, and CSS Color 4 space-separated `hsl()` rejects
|
||||
* bare numbers there. See `apps/remix/app/utils/css-vars.ts`.
|
||||
*/
|
||||
const EXPECTED_CSS_VARS = {
|
||||
'--background': '0 100 50',
|
||||
'--primary': '120 100 50',
|
||||
'--background': '0 100% 50%',
|
||||
'--primary': '120 100% 50%',
|
||||
'--radius': '1rem',
|
||||
};
|
||||
|
||||
@@ -64,7 +68,7 @@ const enableEmbedAuthoringWhiteLabel = async (userId: number) => {
|
||||
const DEFAULT_BODY_BG_COLOR = 'rgb(255, 255, 255)';
|
||||
|
||||
/**
|
||||
* When `--background` is set to `0 100 50` (hsl(0, 100%, 50%)) the body background
|
||||
* When `--background` is set to `0 100% 50%` (hsl(0, 100%, 50%)) the body background
|
||||
* resolves to pure red via the Tailwind `bg-background` → `hsl(var(--background))` chain.
|
||||
*/
|
||||
const INJECTED_BODY_BG_COLOR = 'rgb(255, 0, 0)';
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
isEmailDomainAllowedForSignup,
|
||||
isSignupEnabledForProvider,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
|
||||
|
||||
@@ -124,9 +124,7 @@ export const isEmailDomainAllowedForSignup = (email: string): boolean => {
|
||||
* Check if signup is enabled for the given provider.
|
||||
* The master switch takes precedence over the per-provider flags.
|
||||
*/
|
||||
export const isSignupEnabledForProvider = (
|
||||
provider: 'email' | 'google' | 'microsoft' | 'oidc',
|
||||
): boolean => {
|
||||
export const isSignupEnabledForProvider = (provider: 'email' | 'google' | 'microsoft' | 'oidc'): boolean => {
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Maximum length (in characters) of the user-supplied custom CSS for branding.
|
||||
* Bound enforced at the TRPC request boundary on both the organisation and
|
||||
* team settings update routes. The sanitiser is run after this check; this
|
||||
* limit is purely a request-size guard.
|
||||
*
|
||||
* 256 KB — generous enough for hand-written branding CSS and the occasional
|
||||
* compiled-from-Tailwind-or-similar paste, while still keeping a request
|
||||
* cap so a malicious or runaway payload can't exhaust PostCSS/server memory.
|
||||
*/
|
||||
export const BRANDING_CSS_MAX_LENGTH = 256 * 1024;
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { TCssVarsSchema } from '../types/css-vars';
|
||||
|
||||
/**
|
||||
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
*
|
||||
* KEEP THIS FILE IN SYNC WITH `packages/ui/styles/theme.css`.
|
||||
*
|
||||
* These are the light-mode default values for the CSS custom properties
|
||||
* defined under `:root` in the theme stylesheet, exposed here as hex strings
|
||||
* so they can be used as defaults for colour-picker UI components and other
|
||||
* places that don't render through CSS variables.
|
||||
*
|
||||
* If you change a value in `theme.css`, update it here too. There is NO
|
||||
* automated check linking the two files; they have drifted historically
|
||||
* and will drift again unless you update both.
|
||||
*
|
||||
* Computed via `colord({ h, s, l }).toHex()` — see the inline HSL comments
|
||||
* for the source-of-truth values from `theme.css`.
|
||||
*
|
||||
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
*/
|
||||
export const DEFAULT_BRAND_COLORS = {
|
||||
background: '#ffffff', // 0 0% 100%
|
||||
foreground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
muted: '#f1f5f9', // 210 40% 96.1%
|
||||
mutedForeground: '#64748b', // 215.4 16.3% 46.9%
|
||||
popover: '#ffffff', // 0 0% 100%
|
||||
popoverForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
card: '#ffffff', // 0 0% 100%
|
||||
cardBorder: '#e2e8f0', // 214.3 31.8% 91.4%
|
||||
cardForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
fieldCard: '#e2f8d3', // 95 74% 90%
|
||||
fieldCardBorder: '#a2e771', // 95.08 71.08% 67.45%
|
||||
fieldCardForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
widget: '#f7f7f7', // 0 0% 97%
|
||||
widgetForeground: '#f2f2f2', // 0 0% 95%
|
||||
border: '#e2e8f0', // 214.3 31.8% 91.4%
|
||||
input: '#e2e8f0', // 214.3 31.8% 91.4%
|
||||
primary: '#a2e771', // 95.08 71.08% 67.45%
|
||||
primaryForeground: '#162c07', // 95.08 71.08% 10%
|
||||
secondary: '#f1f5f9', // 210 40% 96.1%
|
||||
secondaryForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
accent: '#f1f5f9', // 210 40% 96.1%
|
||||
accentForeground: '#0f172a', // 222.2 47.4% 11.2%
|
||||
destructive: '#ff0000', // 0 100% 50%
|
||||
destructiveForeground: '#f8fafc', // 210 40% 98%
|
||||
ring: '#a2e771', // 95.08 71.08% 67.45%
|
||||
warning: '#e1cb05', // 54 96% 45%
|
||||
envelopeEditorBackground: '#f8fafc', //210 40% 98.04%
|
||||
// `cardBorderTint` is intentionally excluded from the colour-picker UI:
|
||||
// unlike the rest of these tokens it is consumed via `rgb(var(--token))`
|
||||
// (not `hsl(...)`) and stored as raw RGB triplets in `theme.css`. It does
|
||||
// not flow through `toNativeCssVars` and is not user-customisable from the
|
||||
// branding form. `radius` is a length, not a colour, so it lives in
|
||||
// `DEFAULT_BRAND_RADIUS` below.
|
||||
} as const satisfies Record<keyof Omit<TCssVarsSchema, 'radius' | 'cardBorderTint'>, string>;
|
||||
|
||||
export const DEFAULT_BRAND_RADIUS = '0.5rem';
|
||||
@@ -57,6 +57,8 @@
|
||||
"pino": "^9.14.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"playwright": "1.56.1",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"posthog-js": "^1.297.2",
|
||||
"posthog-node": "4.18.0",
|
||||
"react": "^18",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import type { TCssVarsSchema } from '../../types/css-vars';
|
||||
import { ZCssVarsSchema } from '../../types/css-vars';
|
||||
import { getOrganisationClaimByTeamId } from '../organisation/get-organisation-claims';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
|
||||
export type RecipientBrandingPayload = {
|
||||
allowCustomBranding: boolean;
|
||||
hidePoweredBy: boolean;
|
||||
colors: TCssVarsSchema | null;
|
||||
css: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the branding payload for a recipient-facing route, given the team
|
||||
* the envelope/document belongs to. Reads inherited team-or-org branding settings,
|
||||
* checks the org's claim flags, and returns a payload safe to send to the client.
|
||||
*
|
||||
* Returns a minimal disabled payload if the team is not on a plan that allows
|
||||
* custom branding.
|
||||
*/
|
||||
export const loadRecipientBrandingByTeamId = async ({
|
||||
teamId,
|
||||
}: {
|
||||
teamId: number;
|
||||
}): Promise<RecipientBrandingPayload> => {
|
||||
const billingEnabled = IS_BILLING_ENABLED();
|
||||
|
||||
const [settings, claim] = await Promise.all([
|
||||
getTeamSettings({ teamId }),
|
||||
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
|
||||
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
|
||||
|
||||
if (!allowCustomBranding) {
|
||||
return {
|
||||
allowCustomBranding: false,
|
||||
hidePoweredBy,
|
||||
colors: null,
|
||||
css: null,
|
||||
};
|
||||
}
|
||||
|
||||
// brandingColors is stored as JSON; parse defensively. Drop unknown keys via Zod.
|
||||
const parsedColors = settings.brandingColors ? ZCssVarsSchema.safeParse(settings.brandingColors) : null;
|
||||
|
||||
return {
|
||||
allowCustomBranding: true,
|
||||
hidePoweredBy,
|
||||
colors: parsedColors?.success ? parsedColors.data : null,
|
||||
css: settings.brandingCss && settings.brandingCss.length > 0 ? settings.brandingCss : null,
|
||||
};
|
||||
};
|
||||
@@ -39,6 +39,8 @@ export const getTeamSettings = async ({ userId, teamId }: GetTeamSettingsOptions
|
||||
teamSettings.brandingLogo = organisationSettings.brandingLogo;
|
||||
teamSettings.brandingUrl = organisationSettings.brandingUrl;
|
||||
teamSettings.brandingCompanyDetails = organisationSettings.brandingCompanyDetails;
|
||||
teamSettings.brandingColors = organisationSettings.brandingColors;
|
||||
teamSettings.brandingCss = organisationSettings.brandingCss;
|
||||
}
|
||||
|
||||
return extractDerivedTeamSettings(organisationSettings, teamSettings);
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* A CSS length value: `0`, or a positive number followed by a length unit.
|
||||
* Used for the radius field, which is interpolated raw into a `<style>`
|
||||
* block at render time. Anything outside this shape is a CSS-injection
|
||||
* vector — DO NOT loosen without re-checking `toNativeCssVars`.
|
||||
*/
|
||||
export const CSS_LENGTH_REGEX = /^(0|\d+(\.\d+)?(rem|px|em|%|pt|))$/i;
|
||||
|
||||
export const ZCssVarsSchema = z
|
||||
.object({
|
||||
background: z.string().optional().describe('Base background color'),
|
||||
@@ -28,7 +36,11 @@ export const ZCssVarsSchema = z
|
||||
destructive: z.string().optional().describe('Destructive/danger action color'),
|
||||
destructiveForeground: z.string().optional().describe('Destructive/danger text color'),
|
||||
ring: z.string().optional().describe('Focus ring color'),
|
||||
radius: z.string().optional().describe('Border radius size in REM units'),
|
||||
radius: z
|
||||
.string()
|
||||
.regex(CSS_LENGTH_REGEX, 'Must be a CSS length such as 0.5rem, 8px, or 0')
|
||||
.optional()
|
||||
.describe('Border radius — must be a CSS length (rem/px/em/%/pt or 0)'),
|
||||
warning: z.string().optional().describe('Warning/alert color'),
|
||||
envelopeEditorBackground: z.string().optional().describe('Envelope editor background color'),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { TCssVarsSchema } from '../types/css-vars';
|
||||
|
||||
/**
|
||||
* Normalise a branding-colours payload coming from a settings form.
|
||||
*
|
||||
* The colour-pickers store empty strings for cleared fields, and
|
||||
* `ZCssVarsSchema.default({})` produces `{}` when the form is submitted
|
||||
* without any colour overrides. Persisting either as a non-null value would
|
||||
* silently mask the org's defaults for a team, and produce noisy "this is
|
||||
* an override of nothing" rows in the database.
|
||||
*
|
||||
* This helper:
|
||||
* - strips keys whose value is `undefined`, `null`, or an empty string
|
||||
* - returns `null` if the result has no remaining keys
|
||||
* - leaves all other keys verbatim (validation against ZCssVarsSchema is
|
||||
* expected to have happened at the request boundary)
|
||||
*
|
||||
* `undefined` input means "no change" — the caller should not pass it
|
||||
* through to Prisma. We pass it through unchanged so handlers can keep their
|
||||
* existing `=== undefined` branches.
|
||||
*/
|
||||
export const normalizeBrandingColors = (
|
||||
input: TCssVarsSchema | null | undefined,
|
||||
): TCssVarsSchema | null | undefined => {
|
||||
if (input === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (input === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleaned: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(cleaned).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cleaned as TCssVarsSchema;
|
||||
};
|
||||
@@ -124,6 +124,8 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
|
||||
brandingLogo: '',
|
||||
brandingUrl: '',
|
||||
brandingCompanyDetails: '',
|
||||
brandingColors: null,
|
||||
brandingCss: '',
|
||||
|
||||
emailId: null,
|
||||
emailReplyTo: null,
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { sanitizeBrandingCss } from './sanitize-branding-css';
|
||||
|
||||
const normalize = (css: string) => css.replace(/\s+/g, ' ').trim();
|
||||
|
||||
/**
|
||||
* The sanitiser does NOT scope selectors. Scoping is applied at render time
|
||||
* by wrapping the entire sanitised output in `.documenso-branded { ... }` via
|
||||
* native CSS nesting (see `RecipientBranding`). These tests assert that
|
||||
* selectors are preserved verbatim and only validated.
|
||||
*/
|
||||
describe('sanitizeBrandingCss', () => {
|
||||
describe('empty input', () => {
|
||||
it('returns empty output for an empty string', () => {
|
||||
const result = sanitizeBrandingCss('');
|
||||
|
||||
expect(result.css).toBe('');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty output for whitespace-only input', () => {
|
||||
const result = sanitizeBrandingCss(' \n\t \n');
|
||||
|
||||
expect(result.css).toBe('');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selector preservation', () => {
|
||||
it('preserves a bare class selector', () => {
|
||||
const result = sanitizeBrandingCss('.foo { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('.foo { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves a tag selector', () => {
|
||||
const result = sanitizeBrandingCss('h1 { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('h1 { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves combinators', () => {
|
||||
const result = sanitizeBrandingCss('.a > .b + .c ~ .d { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('.a > .b + .c ~ .d { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves comma-separated selectors', () => {
|
||||
const result = sanitizeBrandingCss('.a, .b { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('.a, .b { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves body/html/:root verbatim (will no-op once nested at render)', () => {
|
||||
const result = sanitizeBrandingCss('body { background: black; }');
|
||||
|
||||
// Selector is left as-is. At render time this becomes
|
||||
// `.documenso-branded body { ... }`, which won't match anything since
|
||||
// <body> is an ancestor of the wrapper. Documented tradeoff.
|
||||
expect(normalize(result.css)).toBe('body { background: black; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pseudo-elements', () => {
|
||||
it('drops a rule containing ::before', () => {
|
||||
const result = sanitizeBrandingCss(".foo::before { content: 'x'; }");
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('selector');
|
||||
});
|
||||
|
||||
it('drops a rule containing ::after', () => {
|
||||
const result = sanitizeBrandingCss(".foo::after { content: 'x'; }");
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops a rule containing ::backdrop', () => {
|
||||
const result = sanitizeBrandingCss('.foo::backdrop { color: red; }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops a rule containing ::marker', () => {
|
||||
const result = sanitizeBrandingCss('li::marker { color: red; }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops a rule using legacy single-colon :before', () => {
|
||||
const result = sanitizeBrandingCss(".foo:before { content: 'x'; }");
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('selector');
|
||||
});
|
||||
|
||||
it('drops a rule using legacy single-colon :after', () => {
|
||||
const result = sanitizeBrandingCss(".foo:after { content: 'x'; }");
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps ::placeholder verbatim', () => {
|
||||
const result = sanitizeBrandingCss('input::placeholder { color: gray; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('input::placeholder { color: gray; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps ::selection verbatim', () => {
|
||||
const result = sanitizeBrandingCss('p::selection { background: yellow; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('p::selection { background: yellow; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('universal selector', () => {
|
||||
it('drops a bare * selector rule', () => {
|
||||
const result = sanitizeBrandingCss('* { color: red; }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('selector');
|
||||
});
|
||||
|
||||
it('drops a rule with * combined with descendant', () => {
|
||||
const result = sanitizeBrandingCss('* .x { color: red; }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps attribute selectors that include * inside', () => {
|
||||
const result = sanitizeBrandingCss('[class*="foo"] { color: red; }');
|
||||
|
||||
expect(normalize(result.css)).toBe('[class*="foo"] { color: red; }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocked properties', () => {
|
||||
const blockedProperties = [
|
||||
'display',
|
||||
'visibility',
|
||||
'opacity',
|
||||
'pointer-events',
|
||||
'position',
|
||||
'inset',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'z-index',
|
||||
'transform',
|
||||
'clip',
|
||||
'clip-path',
|
||||
'mask',
|
||||
'mask-image',
|
||||
'content',
|
||||
'width',
|
||||
'height',
|
||||
'min-width',
|
||||
'min-height',
|
||||
'max-width',
|
||||
'max-height',
|
||||
'overflow',
|
||||
'overflow-x',
|
||||
'overflow-y',
|
||||
'font-size',
|
||||
'letter-spacing',
|
||||
'word-spacing',
|
||||
'line-height',
|
||||
'text-indent',
|
||||
];
|
||||
|
||||
for (const prop of blockedProperties) {
|
||||
it(`strips the "${prop}" property`, () => {
|
||||
const result = sanitizeBrandingCss(`.x { ${prop}: 10px; color: red; }`);
|
||||
|
||||
expect(result.css).not.toContain(`${prop}:`);
|
||||
expect(result.css).toContain('color: red');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('property');
|
||||
expect(result.warnings[0].detail).toContain(prop);
|
||||
});
|
||||
}
|
||||
|
||||
it('is case-insensitive on property names', () => {
|
||||
const result = sanitizeBrandingCss('.x { DISPLAY: none; color: red; }');
|
||||
|
||||
expect(result.css).not.toMatch(/display/i);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('property');
|
||||
});
|
||||
|
||||
const allowedProperties: Array<[string, string]> = [
|
||||
['color', 'red'],
|
||||
['background', '#fff'],
|
||||
['border', '1px solid black'],
|
||||
['border-radius', '4px'],
|
||||
['font-family', 'sans-serif'],
|
||||
['font-weight', '600'],
|
||||
];
|
||||
|
||||
for (const [prop, value] of allowedProperties) {
|
||||
it(`keeps the "${prop}" property`, () => {
|
||||
const result = sanitizeBrandingCss(`.x { ${prop}: ${value}; }`);
|
||||
|
||||
expect(result.css).toContain(`${prop}: ${value}`);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('blocked values', () => {
|
||||
it('drops a declaration containing url(', () => {
|
||||
const result = sanitizeBrandingCss('.x { background: url(http://evil); }');
|
||||
|
||||
expect(result.css).not.toContain('url(');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('value');
|
||||
});
|
||||
|
||||
it('drops a declaration containing expression(', () => {
|
||||
const result = sanitizeBrandingCss('.x { background: expression(alert(1)); }');
|
||||
|
||||
expect(result.css).not.toContain('expression(');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('value');
|
||||
});
|
||||
|
||||
it('drops a declaration containing javascript: in a quoted value', () => {
|
||||
// PostCSS would throw on bare `javascript:alert(1)` (looks like a
|
||||
// malformed selector inside a declaration). Use a quoted value to
|
||||
// exercise the substring match cleanly.
|
||||
const result = sanitizeBrandingCss('.x { font-family: "javascript:alert"; }');
|
||||
|
||||
expect(result.css).not.toContain('javascript:');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('!important stripping', () => {
|
||||
it('strips !important from a retained declaration', () => {
|
||||
const result = sanitizeBrandingCss('.x { color: red !important; }');
|
||||
|
||||
expect(result.css).not.toContain('!important');
|
||||
expect(result.css).toContain('color: red');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('at-rules', () => {
|
||||
it('drops @import', () => {
|
||||
const result = sanitizeBrandingCss('@import url("https://evil.example/x.css");');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('at-rule');
|
||||
});
|
||||
|
||||
it('drops @font-face', () => {
|
||||
const result = sanitizeBrandingCss('@font-face { font-family: "X"; src: url("x.woff2"); }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('at-rule');
|
||||
});
|
||||
|
||||
it('drops @keyframes', () => {
|
||||
const result = sanitizeBrandingCss('@keyframes spin { to { transform: rotate(360deg); } }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('at-rule');
|
||||
});
|
||||
|
||||
it('drops @supports', () => {
|
||||
const result = sanitizeBrandingCss('@supports (display: grid) { .x { color: red; } }');
|
||||
|
||||
expect(result.css.trim()).toBe('');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('at-rule');
|
||||
});
|
||||
|
||||
it('keeps @media with min-width and preserves inner selectors verbatim', () => {
|
||||
const result = sanitizeBrandingCss('@media (min-width: 600px) { .x { color: red; } }');
|
||||
|
||||
expect(normalize(result.css)).toBe('@media (min-width: 600px) { .x { color: red; } }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps @media with prefers-color-scheme and preserves body inside', () => {
|
||||
const result = sanitizeBrandingCss('@media (prefers-color-scheme: dark) { body { background: black; } }');
|
||||
|
||||
expect(normalize(result.css)).toBe('@media (prefers-color-scheme: dark) { body { background: black; } }');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips blocked properties inside @media', () => {
|
||||
const result = sanitizeBrandingCss('@media (min-width: 600px) { .x { display: none; color: red; } }');
|
||||
|
||||
expect(result.css).not.toContain('display');
|
||||
expect(result.css).toContain('color: red');
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].kind).toBe('property');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined input', () => {
|
||||
it('keeps valid rules verbatim and reports each drop', () => {
|
||||
const input = `
|
||||
.ok { color: red !important; }
|
||||
.bad-prop { display: none; background: blue; }
|
||||
.bad-pseudo::before { content: 'x'; }
|
||||
* { color: red; }
|
||||
@import "evil.css";
|
||||
body { background: black; }
|
||||
@media (min-width: 600px) {
|
||||
.responsive { color: green; }
|
||||
}
|
||||
`;
|
||||
|
||||
const result = sanitizeBrandingCss(input);
|
||||
|
||||
// Valid bits present, unchanged.
|
||||
expect(result.css).toContain('.ok');
|
||||
expect(result.css).toContain('color: red');
|
||||
expect(result.css).toContain('.bad-prop');
|
||||
expect(result.css).toContain('background: blue');
|
||||
expect(result.css).toContain('body { background: black');
|
||||
expect(result.css).toContain('@media (min-width: 600px)');
|
||||
expect(result.css).toContain('.responsive');
|
||||
|
||||
// Invalid bits gone.
|
||||
expect(result.css).not.toContain('!important');
|
||||
expect(result.css).not.toContain('display');
|
||||
expect(result.css).not.toContain('::before');
|
||||
expect(result.css).not.toContain('@import');
|
||||
|
||||
// Warning kinds.
|
||||
const kinds = result.warnings.map((w) => w.kind).sort();
|
||||
expect(kinds).toEqual(['at-rule', 'property', 'selector', 'selector'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('style-close-tag defence', () => {
|
||||
// The sanitised output is inlined into a `<style>` element via SSR. The
|
||||
// browser's HTML parser terminates the element on a literal `</style`
|
||||
// anywhere in the content. PostCSS's serializer normally escapes `<` to
|
||||
// `\3c` whenever it would form `</...`, so the literal sequence should
|
||||
// never reach the output for any of these inputs. These tests pin that
|
||||
// invariant.
|
||||
|
||||
it('escapes </style> inside a string value', () => {
|
||||
const result = sanitizeBrandingCss('.x { font-family: "</style><img src=x onerror=alert(1)>"; }');
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
// Whatever else happens, the canonical close-tag bytes must not appear.
|
||||
});
|
||||
|
||||
it('escapes </style> inside a CSS comment', () => {
|
||||
const result = sanitizeBrandingCss('.x { color: red; /* </style><script>alert(1)</script> */ }');
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
|
||||
it('escapes </style> inside an at-rule params block', () => {
|
||||
const result = sanitizeBrandingCss(
|
||||
'@media screen and (foo: bar)</style><script>x()</script> { .x { color: red; } }',
|
||||
);
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
|
||||
it('escapes mixed-case </StYlE> in a value', () => {
|
||||
const result = sanitizeBrandingCss('.x { font-family: "</StYlE>foo"; }');
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
|
||||
it('escapes </style> in an attribute selector value', () => {
|
||||
const result = sanitizeBrandingCss('[data-x="</style><script>alert(1)</script>"] { color: red; }');
|
||||
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
|
||||
it('preserves benign < not followed by /', () => {
|
||||
// `<script>` (no slash) is not a tag close; PostCSS leaves it as text
|
||||
// and the HTML parser treats it as text inside <style> rawtext mode.
|
||||
const result = sanitizeBrandingCss('.x { font-family: "<script>alert(1)</script>"; }');
|
||||
|
||||
// The output keeps the literal `<script>` (harmless) but escapes the
|
||||
// `</script>` end tag's `<` for the same reason it'd escape `</style>`.
|
||||
expect(result.css).toContain('<script>');
|
||||
expect(result.css.toLowerCase()).not.toContain('</style');
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed CSS', () => {
|
||||
// PostCSS is forgiving; an empty value parses without throwing.
|
||||
it('handles a declaration with an empty value gracefully', () => {
|
||||
const result = sanitizeBrandingCss('.x { color: }');
|
||||
|
||||
expect(result.warnings.filter((w) => w.kind === 'parse-error')).toEqual([]);
|
||||
expect(result.css).toContain('.x');
|
||||
});
|
||||
|
||||
it('reports a parse-error for clearly broken CSS', () => {
|
||||
// Unclosed brace.
|
||||
const result = sanitizeBrandingCss('.x { color: red');
|
||||
|
||||
// PostCSS may or may not throw on this; if it does, we get a
|
||||
// parse-error warning. If it tolerates it, the rule is sanitized.
|
||||
if (result.css === '') {
|
||||
expect(result.warnings.some((w) => w.kind === 'parse-error')).toBe(true);
|
||||
} else {
|
||||
expect(result.css).toContain('.x');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { AtRule, Container, Declaration, Rule } from 'postcss';
|
||||
import postcss from 'postcss';
|
||||
import selectorParser from 'postcss-selector-parser';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZSanitizeBrandingCssWarningSchema = z.object({
|
||||
kind: z.enum(['selector', 'property', 'value', 'at-rule', 'parse-error']),
|
||||
detail: z.string(),
|
||||
line: z.number().optional(),
|
||||
});
|
||||
|
||||
export type SanitizeBrandingCssWarning = z.infer<typeof ZSanitizeBrandingCssWarningSchema>;
|
||||
|
||||
export type SanitizeBrandingCssResult = {
|
||||
css: string;
|
||||
warnings: SanitizeBrandingCssWarning[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The class name the sanitised CSS will be wrapped in at render time using
|
||||
* CSS nesting (`.documenso-branded { <user css> }`). The sanitiser itself
|
||||
* does NOT prefix selectors — the wrapper is applied by `RecipientBranding`
|
||||
* on every render so we keep the user's original CSS intact in the database.
|
||||
*/
|
||||
export const SANITIZE_BRANDING_SCOPE_CLASS = 'documenso-branded';
|
||||
|
||||
const BLOCKED_PROPERTIES = new Set([
|
||||
'display',
|
||||
'visibility',
|
||||
'opacity',
|
||||
'pointer-events',
|
||||
'position',
|
||||
'inset',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'left',
|
||||
'z-index',
|
||||
'transform',
|
||||
'clip',
|
||||
'clip-path',
|
||||
'mask',
|
||||
'mask-image',
|
||||
'content',
|
||||
'width',
|
||||
'height',
|
||||
'min-width',
|
||||
'min-height',
|
||||
'max-width',
|
||||
'max-height',
|
||||
'overflow',
|
||||
'overflow-x',
|
||||
'overflow-y',
|
||||
'font-size',
|
||||
'letter-spacing',
|
||||
'word-spacing',
|
||||
'line-height',
|
||||
'text-indent',
|
||||
]);
|
||||
|
||||
const BLOCKED_VALUE_SUBSTRINGS = ['url(', 'expression(', '@import', 'javascript:'];
|
||||
|
||||
const BLOCKED_PSEUDO_ELEMENTS = new Set([
|
||||
'::before',
|
||||
'::after',
|
||||
'::backdrop',
|
||||
'::marker',
|
||||
// Single-colon legacy forms.
|
||||
':before',
|
||||
':after',
|
||||
]);
|
||||
|
||||
const BLOCKED_AT_RULES = new Set([
|
||||
'import',
|
||||
'font-face',
|
||||
'keyframes',
|
||||
'charset',
|
||||
'namespace',
|
||||
'supports',
|
||||
'page',
|
||||
'document',
|
||||
'viewport',
|
||||
]);
|
||||
|
||||
type SelectorValidationResult = { kind: 'ok' } | { kind: 'drop'; reason: string };
|
||||
|
||||
/**
|
||||
* Validate a selector for the rules we care about, but DO NOT rewrite it.
|
||||
* The sanitised output is later wrapped in `.documenso-branded { ... }` via
|
||||
* native CSS nesting by `RecipientBranding`, so scoping happens at render.
|
||||
*/
|
||||
const validateSelector = (rawSelector: string): SelectorValidationResult => {
|
||||
let dropReason: string | null = null;
|
||||
|
||||
const transform = selectorParser((selectors) => {
|
||||
selectors.each((selector) => {
|
||||
selector.walk((node) => {
|
||||
// Pseudo-element check (works at any depth — even nested pseudos like
|
||||
// `:is(::before)` should be rejected).
|
||||
if (node.type === 'pseudo') {
|
||||
const value = node.value;
|
||||
|
||||
if (BLOCKED_PSEUDO_ELEMENTS.has(value)) {
|
||||
dropReason = `pseudo-element "${value}" not allowed`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (dropReason !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Universal selector check — only when it is a direct child of the
|
||||
// top-level compound (i.e. `* { ... }` or `* .foo { ... }`).
|
||||
// Universal nodes nested inside attribute selectors (`[class*="x"]`)
|
||||
// are a different node type and won't appear here.
|
||||
selector.each((node) => {
|
||||
if (node.type === 'universal') {
|
||||
dropReason = 'universal "*" selector not allowed';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
// We don't keep the result — we only care about parsing and walking
|
||||
// to populate dropReason.
|
||||
transform.processSync(rawSelector);
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: 'drop',
|
||||
reason: error instanceof Error ? error.message : 'failed to parse selector',
|
||||
};
|
||||
}
|
||||
|
||||
if (dropReason !== null) {
|
||||
return { kind: 'drop', reason: dropReason };
|
||||
}
|
||||
|
||||
return { kind: 'ok' };
|
||||
};
|
||||
|
||||
const valueIsBlocked = (rawValue: string): boolean => {
|
||||
const lowered = rawValue.toLowerCase();
|
||||
|
||||
return BLOCKED_VALUE_SUBSTRINGS.some((needle) => lowered.includes(needle));
|
||||
};
|
||||
|
||||
const sanitizeDeclarations = (container: Container, warnings: SanitizeBrandingCssWarning[]): void => {
|
||||
const toRemove: Declaration[] = [];
|
||||
|
||||
container.each((node) => {
|
||||
if (node.type !== 'decl') {
|
||||
return;
|
||||
}
|
||||
|
||||
const decl = node;
|
||||
const propLower = decl.prop.toLowerCase();
|
||||
|
||||
if (BLOCKED_PROPERTIES.has(propLower)) {
|
||||
warnings.push({
|
||||
kind: 'property',
|
||||
detail: `property "${decl.prop}" is not allowed`,
|
||||
line: decl.source?.start?.line,
|
||||
});
|
||||
toRemove.push(decl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (valueIsBlocked(decl.value)) {
|
||||
warnings.push({
|
||||
kind: 'value',
|
||||
detail: `value of "${decl.prop}" contains a disallowed token`,
|
||||
line: decl.source?.start?.line,
|
||||
});
|
||||
toRemove.push(decl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (decl.important) {
|
||||
decl.important = false;
|
||||
}
|
||||
});
|
||||
|
||||
for (const decl of toRemove) {
|
||||
decl.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeRule = (rule: Rule, warnings: SanitizeBrandingCssWarning[]): void => {
|
||||
const line = rule.source?.start?.line;
|
||||
const validation = validateSelector(rule.selector);
|
||||
|
||||
if (validation.kind === 'drop') {
|
||||
warnings.push({ kind: 'selector', detail: validation.reason, line });
|
||||
rule.remove();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Selector is left as-is. Scoping is applied at render time by wrapping
|
||||
// the entire sanitised CSS in `.documenso-branded { ... }` (CSS nesting).
|
||||
|
||||
sanitizeDeclarations(rule, warnings);
|
||||
|
||||
// If the rule has no declarations left, leave the empty rule in place — the
|
||||
// output is still valid CSS and the user can see what happened. (Removing
|
||||
// it would also be acceptable; we keep it to make warnings easier to map.)
|
||||
};
|
||||
|
||||
const sanitizeAtRule = (atRule: AtRule, warnings: SanitizeBrandingCssWarning[]): void => {
|
||||
const name = atRule.name.toLowerCase();
|
||||
const line = atRule.source?.start?.line;
|
||||
|
||||
if (BLOCKED_AT_RULES.has(name)) {
|
||||
warnings.push({
|
||||
kind: 'at-rule',
|
||||
detail: `at-rule "@${atRule.name}" is not allowed`,
|
||||
line,
|
||||
});
|
||||
atRule.remove();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (name !== 'media') {
|
||||
warnings.push({
|
||||
kind: 'at-rule',
|
||||
detail: `at-rule "@${atRule.name}" is not allowed`,
|
||||
line,
|
||||
});
|
||||
atRule.remove();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse into @media children.
|
||||
const children = atRule.nodes ? [...atRule.nodes] : [];
|
||||
|
||||
for (const child of children) {
|
||||
if (child.type === 'rule') {
|
||||
sanitizeRule(child, warnings);
|
||||
} else if (child.type === 'atrule') {
|
||||
sanitizeAtRule(child, warnings);
|
||||
}
|
||||
// Comments and stray declarations inside @media are left alone /
|
||||
// declarations directly under @media are invalid CSS anyway.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Defence in depth against `<style>` element breakout.
|
||||
*
|
||||
* The sanitised CSS is inlined into a `<style>` element via SSR
|
||||
* `dangerouslySetInnerHTML`. The browser's HTML parser (in RAWTEXT mode for
|
||||
* `<style>` content) terminates the element on a literal `</style` —
|
||||
* regardless of whether it appears inside a CSS string, comment, or at-rule
|
||||
* params. PostCSS's serializer escapes `<` to `\3c` whenever it would form
|
||||
* `</...`, which means a normal round-trip is already safe.
|
||||
*
|
||||
* That escape is implicit in PostCSS, not enforced by our own logic. If a
|
||||
* future PostCSS version, plugin, or alternative serializer regresses, we
|
||||
* still want the output to be safe to inline. This regex is the explicit
|
||||
* tripwire — case-insensitive `</style` anywhere in the final output is a
|
||||
* hard fail.
|
||||
*/
|
||||
const STYLE_CLOSE_TAG_REGEX = /<\s*\/\s*style/i;
|
||||
|
||||
export const sanitizeBrandingCss = (input: string): SanitizeBrandingCssResult => {
|
||||
const warnings: SanitizeBrandingCssWarning[] = [];
|
||||
|
||||
if (input.trim() === '') {
|
||||
return { css: '', warnings };
|
||||
}
|
||||
|
||||
let root;
|
||||
|
||||
try {
|
||||
root = postcss.parse(input);
|
||||
} catch (error) {
|
||||
return {
|
||||
css: '',
|
||||
warnings: [
|
||||
{
|
||||
kind: 'parse-error',
|
||||
detail: error instanceof Error ? error.message : 'failed to parse CSS',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Iterate over a snapshot of top-level children so removal during the loop
|
||||
// is safe.
|
||||
const topLevelChildren = root.nodes ? [...root.nodes] : [];
|
||||
|
||||
for (const node of topLevelChildren) {
|
||||
if (node.type === 'rule') {
|
||||
sanitizeRule(node, warnings);
|
||||
} else if (node.type === 'atrule') {
|
||||
sanitizeAtRule(node, warnings);
|
||||
}
|
||||
// Top-level decls / comments are left as-is.
|
||||
}
|
||||
|
||||
const output = root.toString();
|
||||
|
||||
if (STYLE_CLOSE_TAG_REGEX.test(output)) {
|
||||
return {
|
||||
css: '',
|
||||
warnings: [
|
||||
...warnings,
|
||||
{
|
||||
kind: 'parse-error',
|
||||
detail: 'output contained a literal </style sequence and was rejected',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { css: output, warnings };
|
||||
};
|
||||
@@ -191,6 +191,8 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
brandingLogo: null,
|
||||
brandingUrl: null,
|
||||
brandingCompanyDetails: null,
|
||||
brandingColors: null,
|
||||
brandingCss: null,
|
||||
|
||||
emailDocumentSettings: null,
|
||||
emailId: null,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "brandingColors" JSONB,
|
||||
ADD COLUMN "brandingCss" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "brandingColors" JSONB,
|
||||
ADD COLUMN "brandingCss" TEXT;
|
||||
@@ -831,7 +831,7 @@ enum OrganisationMemberInviteStatus {
|
||||
DECLINED
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';"])
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';", "import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';"])
|
||||
model OrganisationGlobalSettings {
|
||||
id String @id
|
||||
organisation Organisation?
|
||||
@@ -862,6 +862,8 @@ model OrganisationGlobalSettings {
|
||||
brandingLogo String @default("")
|
||||
brandingUrl String @default("")
|
||||
brandingCompanyDetails String @default("")
|
||||
brandingColors Json? /// [TCssVarsSchema] @zod.custom.use(ZCssVarsSchema)
|
||||
brandingCss String @default("")
|
||||
|
||||
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
|
||||
|
||||
@@ -871,7 +873,7 @@ model OrganisationGlobalSettings {
|
||||
aiFeaturesEnabled Boolean @default(false)
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';"])
|
||||
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';", "import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';", "import { ZEnvelopeExpirationPeriod as ZEnvelopeExpirationPeriodSchema } from '@documenso/lib/constants/envelope-expiration';", "import { ZEnvelopeReminderSettings as ZEnvelopeReminderSettingsSchema } from '@documenso/lib/constants/envelope-reminder';", "import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';"])
|
||||
model TeamGlobalSettings {
|
||||
id String @id
|
||||
team Team?
|
||||
@@ -903,6 +905,8 @@ model TeamGlobalSettings {
|
||||
brandingLogo String?
|
||||
brandingUrl String?
|
||||
brandingCompanyDetails String?
|
||||
brandingColors Json? /// [TCssVarsSchema] @zod.custom.use(ZCssVarsSchema)
|
||||
brandingCss String?
|
||||
|
||||
envelopeExpirationPeriod Json? /// [EnvelopeExpirationPeriod] @zod.custom.use(ZEnvelopeExpirationPeriodSchema)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
|
||||
@@ -45,6 +47,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors,
|
||||
brandingCss,
|
||||
|
||||
// Email related settings.
|
||||
emailId,
|
||||
@@ -127,6 +131,24 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -155,6 +177,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
||||
brandingCss: sanitizedBrandingCss,
|
||||
|
||||
// Email related settings.
|
||||
emailId,
|
||||
@@ -168,4 +192,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
cssWarnings: cssWarnings && cssWarnings.length > 0 ? cssWarnings : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BRANDING_CSS_MAX_LENGTH } from '@documenso/lib/constants/branding';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { ZDocumentMetaDateFormatSchema, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { ZSanitizeBrandingCssWarningSchema } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -32,6 +35,8 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
||||
brandingLogo: z.string().optional(),
|
||||
brandingUrl: z.string().optional(),
|
||||
brandingCompanyDetails: z.string().optional(),
|
||||
brandingColors: ZCssVarsSchema.nullish(),
|
||||
brandingCss: z.string().max(BRANDING_CSS_MAX_LENGTH).optional(),
|
||||
|
||||
// Email related settings.
|
||||
emailId: z.string().nullish(),
|
||||
@@ -44,4 +49,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateOrganisationSettingsResponseSchema = z.void();
|
||||
export const ZUpdateOrganisationSettingsResponseSchema = z.object({
|
||||
cssWarnings: z.array(ZSanitizeBrandingCssWarningSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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';
|
||||
@@ -43,6 +45,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors,
|
||||
brandingCss,
|
||||
|
||||
// Email related settings.
|
||||
emailId,
|
||||
@@ -127,6 +131,27 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -154,6 +179,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
brandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
brandingColors: normalizedBrandingColors === null ? Prisma.DbNull : normalizedBrandingColors,
|
||||
brandingCss: sanitizedBrandingCss,
|
||||
|
||||
// Email related settings.
|
||||
emailId,
|
||||
@@ -168,4 +195,8 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
cssWarnings: cssWarnings && cssWarnings.length > 0 ? cssWarnings : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BRANDING_CSS_MAX_LENGTH } from '@documenso/lib/constants/branding';
|
||||
import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-expiration';
|
||||
import { ZEnvelopeReminderSettings } from '@documenso/lib/constants/envelope-reminder';
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
|
||||
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
|
||||
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import { ZDocumentMetaDateFormatSchema, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { ZSanitizeBrandingCssWarningSchema } from '@documenso/lib/utils/sanitize-branding-css';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -35,6 +38,8 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
||||
brandingLogo: z.string().nullish(),
|
||||
brandingUrl: z.string().nullish(),
|
||||
brandingCompanyDetails: z.string().nullish(),
|
||||
brandingColors: ZCssVarsSchema.nullish(),
|
||||
brandingCss: z.string().max(BRANDING_CSS_MAX_LENGTH).nullish(),
|
||||
|
||||
// Email related settings.
|
||||
emailId: z.string().nullish(),
|
||||
@@ -49,4 +54,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateTeamSettingsResponseSchema = z.void();
|
||||
export const ZUpdateTeamSettingsResponseSchema = z.object({
|
||||
cssWarnings: z.array(ZSanitizeBrandingCssWarningSchema).optional(),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { HexColorInput, HexColorPicker } from 'react-colorful';
|
||||
import { HexColorInput, HexColorPicker, setNonce } from 'react-colorful';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
@@ -11,6 +11,7 @@ export type ColorPickerProps = {
|
||||
value: string;
|
||||
defaultValue?: string;
|
||||
onChange: (color: string) => void;
|
||||
nonce?: string;
|
||||
} & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ColorPicker = ({
|
||||
@@ -19,6 +20,7 @@ export const ColorPicker = ({
|
||||
value,
|
||||
defaultValue = '#000000',
|
||||
onChange,
|
||||
nonce,
|
||||
...props
|
||||
}: ColorPickerProps) => {
|
||||
const [color, setColor] = useState(value || defaultValue);
|
||||
@@ -39,6 +41,12 @@ export const ColorPicker = ({
|
||||
onChange(inputColor);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (nonce) {
|
||||
setNonce(nonce);
|
||||
}
|
||||
}, [nonce]);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
@@ -57,6 +65,7 @@ export const ColorPicker = ({
|
||||
color={color}
|
||||
onChange={onColorChange}
|
||||
aria-disabled={disabled}
|
||||
nonce={nonce}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -72,6 +81,7 @@ export const ColorPicker = ({
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
nonce={nonce}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
Reference in New Issue
Block a user