feat: add custom branding for signing pages (#2785)

Platform-plan organisations and teams can now customise non-embed
signing pages with six brand colour tokens, a border-radius, and
a free-text custom CSS block (up to 256 KB).

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