Merge branch 'main' into feat/delete-archive

This commit is contained in:
Mythie
2024-11-27 10:57:13 +11:00
82 changed files with 2450 additions and 1820 deletions

View File

@ -27,9 +27,6 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/marketing",
"version": "1.8.1-rc.0",
"version": "1.8.1-rc.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.8.1-rc.0",
"version": "1.8.1-rc.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@ -28,6 +28,7 @@
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5",
"colord": "^2.9.3",
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
@ -53,7 +54,7 @@
"react-icons": "^4.11.0",
"react-rnd": "^10.4.1",
"recharts": "^2.7.2",
"remeda": "^2.12.1",
"remeda": "^2.17.3",
"sharp": "0.32.6",
"ts-pattern": "^5.0.5",
"ua-parser-js": "^1.0.37",

View File

@ -146,7 +146,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<div className="flex flex-row justify-between truncate">
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
@ -218,7 +221,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm ">
<p className="text-muted-foreground mt-2 px-4 text-sm">
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>

View File

@ -109,7 +109,10 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<Trans>Documents</Trans>
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>

View File

@ -121,7 +121,10 @@ export const DocumentLogsPageView = async ({ params, team }: DocumentLogsPageVie
<div className="flex flex-col justify-between truncate sm:flex-row">
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>

View File

@ -141,6 +141,23 @@ export const EditTemplateForm = ({
},
});
const { mutateAsync: updateTypedSignature } =
trpc.template.updateTemplateTypedSignatureSettings.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateWithDetailsById.setData(
{
id: initialTemplate.id,
},
(oldData) => ({
...(oldData || initialTemplate),
...newData,
id: Number(newData.id),
}),
);
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
@ -211,6 +228,12 @@ export const EditTemplateForm = ({
fields: data.fields,
});
await updateTypedSignature({
templateId: template.id,
teamId: team?.id,
typedSignatureEnabled: data.typedSignatureEnabled,
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
@ -225,14 +248,13 @@ export const EditTemplateForm = ({
duration: 5000,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
router.push(templateRootPath);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
description: _(msg`An error occurred while adding fields.`),
variant: 'destructive',
});
}
@ -301,6 +323,7 @@ export const EditTemplateForm = ({
fields={fields}
onSubmit={onAddFieldsFormSubmit}
teamId={team?.id}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/>
</Stepper>
</DocumentFlowFormContainer>

View File

@ -63,7 +63,10 @@ export const TemplateEditPageView = async ({ params, team }: TemplateEditPageVie
<Trans>Template</Trans>
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>

View File

@ -73,7 +73,6 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
const mockedDocumentMeta = templateMeta
? {
typedSignatureEnabled: false,
...templateMeta,
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
documentId: 0,
@ -89,7 +88,10 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
<div className="flex flex-row justify-between truncate">
<div>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
@ -155,7 +157,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
</div>
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm ">
<p className="text-muted-foreground mt-2 px-4 text-sm">
<Trans>Manage and view template</Trans>
</p>

View File

@ -209,11 +209,19 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
boxShadow: `0px 0px 0px 4.88px rgba(122, 196, 85, 0.1), 0px 0px 0px 1.22px rgba(122, 196, 85, 0.6), 0px 0px 0px 0.61px rgba(122, 196, 85, 1)`,
}}
>
<img
src={`${signature.Signature?.signatureImageAsBase64}`}
alt="Signature"
className="max-h-12 max-w-full"
/>
{signature.Signature?.signatureImageAsBase64 && (
<img
src={`${signature.Signature?.signatureImageAsBase64}`}
alt="Signature"
className="max-h-12 max-w-full"
/>
)}
{signature.Signature?.typedSignature && (
<p className="font-signature text-center text-sm">
{signature.Signature?.typedSignature}
</p>
)}
</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">

View File

@ -12,7 +12,6 @@ import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentAuthProvider } from '~/app/(signing)/sign/[token]/document-auth-provider';
import { SigningProvider } from '~/app/(signing)/sign/[token]/provider';
import { truncateTitle } from '~/helpers/truncate-title';
import { DirectTemplatePageView } from './direct-template';
import { DirectTemplateAuthPageView } from './signing-auth-page';
@ -72,8 +71,11 @@ export default async function TemplatesDirectPage({ params }: TemplatesDirectPag
user={user}
>
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{truncateTitle(template.title)}
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">

View File

@ -102,9 +102,9 @@ export const SignDirectTemplateForm = ({
created: new Date(),
recipientId: 1,
fieldId: 1,
signatureImageAsBase64: value.value,
typedSignature: null,
};
signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null,
typedSignature: value.value.startsWith('data:') ? null : value.value,
} satisfies Signature;
}
if (field.type === FieldType.DATE) {

View File

@ -24,8 +24,6 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page';
import { ClaimAccount } from './claim-account';
import { DocumentPreviewButton } from './document-preview-button';
@ -61,8 +59,6 @@ export default async function CompletedSigningPage({
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const { documentData } = document;
const [fields, recipient] = await Promise.all([
@ -118,7 +114,9 @@ export default async function CompletedSigningPage({
})}
>
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
{document.title}
</span>
</Badge>
{/* Card with recipient */}

View File

@ -1,6 +1,6 @@
'use client';
import { createContext, useContext, useState } from 'react';
import { createContext, useContext, useEffect, useState } from 'react';
export type SigningContextValue = {
fullName: string;
@ -44,6 +44,12 @@ export const SigningProvider = ({
const [email, setEmail] = useState(initialEmail || '');
const [signature, setSignature] = useState(initialSignature || null);
useEffect(() => {
if (initialSignature) {
setSignature(initialSignature);
}
}, [initialSignature]);
return (
<SigningContext.Provider
value={{

View File

@ -14,7 +14,6 @@ import {
} from '@documenso/ui/primitives/dialog';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { truncateTitle } from '~/helpers/truncate-title';
export type SignDialogProps = {
isSubmitting: boolean;
@ -36,7 +35,7 @@ export const SignDialog = ({
disabled = false,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(documentTitle);
const isComplete = fields.every((field) => field.inserted);
const handleOpenChange = (open: boolean) => {
@ -75,7 +74,13 @@ export const SignDialog = ({
{role === RecipientRole.VIEWER && (
<span>
<Trans>
You are about to complete viewing "{truncatedTitle}".
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
@ -83,7 +88,13 @@ export const SignDialog = ({
{role === RecipientRole.SIGNER && (
<span>
<Trans>
You are about to complete signing "{truncatedTitle}".
<span className="inline-flex flex-wrap">
You are about to complete signing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
@ -91,7 +102,13 @@ export const SignDialog = ({
{role === RecipientRole.APPROVER && (
<span>
<Trans>
You are about to complete approving "{truncatedTitle}".
<span className="inline-flex flex-wrap">
You are about to complete approving{' '}
<span className="inline-block max-w-[11rem] truncate align-baseline">
"{documentTitle}"
</span>
.
</span>
<br /> Are you sure?
</Trans>
</span>

View File

@ -1,6 +1,6 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { useLayoutEffect, useMemo, useRef, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
@ -51,6 +51,10 @@ export const SignatureField = ({
const { _ } = useLingui();
const { toast } = useToast();
const signatureRef = useRef<HTMLParagraphElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState(2);
const { signature: providedSignature, setSignature: setProvidedSignature } =
useRequiredSigningContext();
@ -108,6 +112,7 @@ export const SignatureField = ({
actionTarget: field.type,
});
};
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
try {
const value = signature || providedSignature;
@ -117,11 +122,23 @@ export const SignatureField = ({
return;
}
const isTypedSignature = !value.startsWith('data:image');
if (isTypedSignature && !typedSignatureEnabled) {
toast({
title: _(msg`Error`),
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
variant: 'destructive',
});
return;
}
const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token,
fieldId: field.id,
value,
isBase64: true,
isBase64: !isTypedSignature,
authOptions,
};
@ -176,6 +193,41 @@ export const SignatureField = ({
}
};
useLayoutEffect(() => {
if (!signatureRef.current || !containerRef.current || !signature?.typedSignature) {
return;
}
const adjustTextSize = () => {
const container = containerRef.current;
const text = signatureRef.current;
if (!container || !text) {
return;
}
let size = 2;
text.style.fontSize = `${size}rem`;
while (
(text.scrollWidth > container.clientWidth || text.scrollHeight > container.clientHeight) &&
size > 0.8
) {
size -= 0.1;
text.style.fontSize = `${size}rem`;
}
setFontSize(size);
};
const resizeObserver = new ResizeObserver(adjustTextSize);
resizeObserver.observe(containerRef.current);
adjustTextSize();
return () => resizeObserver.disconnect();
}, [signature?.typedSignature]);
return (
<SigningFieldContainer
field={field}
@ -205,10 +257,15 @@ export const SignatureField = ({
)}
{state === 'signed-text' && (
<p className="font-signature text-muted-foreground dark:text-background text-lg duration-200 sm:text-xl md:text-2xl lg:text-3xl">
{/* This optional chaining is intentional, we don't want to move the check into the condition above */}
{signature?.typedSignature}
</p>
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
<p
ref={signatureRef}
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
style={{ fontSize: `${fontSize}rem` }}
>
{signature?.typedSignature}
</p>
</div>
)}
<Dialog open={showSignatureModal} onOpenChange={setShowSignatureModal}>

View File

@ -55,7 +55,10 @@ export const SigningPageView = ({
return (
<div className="mx-auto w-full max-w-screen-xl">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>

View File

@ -52,13 +52,7 @@ export default async function TeamsSettingsPage({ params }: TeamsSettingsPagePro
<AvatarImageForm className="mb-8" team={team} user={session.user} />
<UpdateTeamForm
teamId={team.id}
teamName={team.name}
teamUrl={team.url}
documentVisibility={team.teamGlobalSettings?.documentVisibility}
includeSenderDetails={team.teamGlobalSettings?.includeSenderDetails}
/>
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">
{(team.teamEmail || team.emailVerification) && (

View File

@ -39,6 +39,8 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(),
typedSignatureEnabled: z.boolean(),
includeSigningCertificate: z.boolean(),
});
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
@ -68,6 +70,8 @@ export const TeamDocumentPreferencesForm = ({
? settings?.documentLanguage
: 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false,
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
},
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
});
@ -76,7 +80,13 @@ export const TeamDocumentPreferencesForm = ({
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
try {
const { documentVisibility, documentLanguage, includeSenderDetails } = data;
const {
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
} = data;
await updateTeamDocumentPreferences({
teamId: team.id,
@ -84,6 +94,8 @@ export const TeamDocumentPreferencesForm = ({
documentVisibility,
documentLanguage,
includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate,
},
});
@ -105,7 +117,7 @@ export const TeamDocumentPreferencesForm = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-4"
className="flex h-full max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<FormField
@ -227,6 +239,67 @@ export const TeamDocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="typedSignatureEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enable Typed Signature</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the recipients can sign the documents using a typed signature.
Enable or disable the typed signature globally.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="includeSigningCertificate"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Signing Certificate in the Document</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the signing certificate will be included in the document when
it is downloaded. The signing certificate can still be downloaded from the logs
page separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Save</Trans>

View File

@ -1,8 +1,12 @@
import { z } from 'zod';
import { ZCssVarsSchema } from './css-vars';
export const ZBaseEmbedDataSchema = z.object({
darkModeDisabled: z.boolean().optional().default(false),
css: z
.string()
.optional()
.transform((value) => value || undefined),
cssVars: ZCssVarsSchema.optional().default({}),
});

View File

@ -10,6 +10,7 @@ export type EmbedDocumentCompletedPageProps = {
};
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
console.log({ signature });
return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">

View File

@ -0,0 +1,59 @@
import { colord } from 'colord';
import { toSnakeCase } from 'remeda';
import { z } from 'zod';
export const ZCssVarsSchema = z
.object({
background: z.string().optional().describe('Base background color'),
foreground: z.string().optional().describe('Base text color'),
muted: z.string().optional().describe('Muted/subtle background color'),
mutedForeground: z.string().optional().describe('Muted/subtle text color'),
popover: z.string().optional().describe('Popover/dropdown background color'),
popoverForeground: z.string().optional().describe('Popover/dropdown text color'),
card: z.string().optional().describe('Card background color'),
cardBorder: z.string().optional().describe('Card border color'),
cardBorderTint: z.string().optional().describe('Card border tint/highlight color'),
cardForeground: z.string().optional().describe('Card text color'),
fieldCard: z.string().optional().describe('Field card background color'),
fieldCardBorder: z.string().optional().describe('Field card border color'),
fieldCardForeground: z.string().optional().describe('Field card text color'),
widget: z.string().optional().describe('Widget background color'),
widgetForeground: z.string().optional().describe('Widget text color'),
border: z.string().optional().describe('Default border color'),
input: z.string().optional().describe('Input field border color'),
primary: z.string().optional().describe('Primary action/button color'),
primaryForeground: z.string().optional().describe('Primary action/button text color'),
secondary: z.string().optional().describe('Secondary action/button color'),
secondaryForeground: z.string().optional().describe('Secondary action/button text color'),
accent: z.string().optional().describe('Accent/highlight color'),
accentForeground: z.string().optional().describe('Accent/highlight text color'),
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'),
warning: z.string().optional().describe('Warning/alert color'),
})
.describe('Custom CSS variables for theming');
export type TCssVarsSchema = z.infer<typeof ZCssVarsSchema>;
export const toNativeCssVars = (vars: TCssVarsSchema) => {
const cssVars: Record<string, string> = {};
const { radius, ...colorVars } = vars;
for (const [key, value] of Object.entries(colorVars)) {
if (value) {
const color = colord(value);
const { h, s, l } = color.toHsl();
cssVars[`--${toSnakeCase(key)}`] = `${h} ${s} ${l}`;
}
}
if (radius) {
cssVars[`--radius`] = `${radius}`;
}
return cssVars;
};

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
@ -14,7 +14,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { DocumentMeta, Recipient, TemplateMeta } from '@documenso/prisma/client';
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@documenso/prisma/client';
import { type DocumentData, type Field, FieldType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import type {
@ -38,6 +38,7 @@ import { Logo } from '~/components/branding/logo';
import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields';
import { injectCss } from '../../util';
import { ZDirectTemplateEmbedDataSchema } from './schema';
export type EmbedDirectTemplateClientPageProps = {
@ -47,6 +48,8 @@ export type EmbedDirectTemplateClientPageProps = {
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean;
};
export const EmbedDirectTemplateClientPage = ({
@ -56,6 +59,8 @@ export const EmbedDirectTemplateClientPage = ({
recipient,
fields,
metadata,
hidePoweredBy = false,
isPlatformOrEnterprise = false,
}: EmbedDirectTemplateClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -108,9 +113,9 @@ export const EmbedDirectTemplateClientPage = ({
created: new Date(),
recipientId: 1,
fieldId: 1,
signatureImageAsBase64: payload.value,
typedSignature: null,
};
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
} satisfies Signature;
}
if (field.type === FieldType.DATE) {
@ -249,7 +254,7 @@ export const EmbedDirectTemplateClientPage = ({
}
};
useEffect(() => {
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
try {
@ -264,6 +269,17 @@ export const EmbedDirectTemplateClientPage = ({
setFullName(data.name);
setIsNameLocked(!!data.lockName);
}
if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (isPlatformOrEnterprise) {
injectCss({
css: data.css,
cssVars: data.cssVars,
});
}
} catch (err) {
console.error(err);
}
@ -296,8 +312,8 @@ export const EmbedDirectTemplateClientPage = ({
fieldId: 1,
recipientId: 1,
created: new Date(),
typedSignature: null,
signatureImageAsBase64: signature,
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
typedSignature: signature?.startsWith('data:') ? null : signature,
}}
/>
);
@ -452,10 +468,12 @@ export const EmbedDirectTemplateClientPage = ({
/>
</div>
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
</div>
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
);
};

View File

@ -2,8 +2,11 @@ import { notFound } from 'next/navigation';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@ -51,6 +54,14 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
documentAuth: template.authOptions,
});
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
isDocumentPlatform(template),
isUserEnterprise({
userId: template.userId,
teamId: template.teamId ?? undefined,
}),
]);
const isAccessAuthValid = match(derivedRecipientAccessAuth)
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
.with(null, () => true)
@ -72,6 +83,12 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
const fields = template.Field.filter((field) => field.recipientId === directTemplateRecipientId);
const team = template.teamId
? await getTeamById({ teamId: template.teamId, userId: template.userId }).catch(() => null)
: null;
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
return (
<SigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
<DocumentAuthProvider
@ -86,6 +103,8 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem
recipient={recipient}
fields={fields}
metadata={template.templateMeta}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
/>
</DocumentAuthProvider>
</SigningProvider>

View File

@ -58,6 +58,7 @@ export const EmbedDocumentFields = ({
recipient={recipient}
onSignField={onSignField}
onUnsignField={onUnsignField}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
@ -28,6 +28,7 @@ import { Logo } from '~/components/branding/logo';
import { EmbedClientLoading } from '../../client-loading';
import { EmbedDocumentCompleted } from '../../completed';
import { EmbedDocumentFields } from '../../document-fields';
import { injectCss } from '../../util';
import { ZSignDocumentEmbedDataSchema } from './schema';
export type EmbedSignDocumentClientPageProps = {
@ -38,6 +39,8 @@ export type EmbedSignDocumentClientPageProps = {
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean;
hidePoweredBy?: boolean;
isPlatformOrEnterprise?: boolean;
};
export const EmbedSignDocumentClientPage = ({
@ -48,6 +51,8 @@ export const EmbedSignDocumentClientPage = ({
fields,
metadata,
isCompleted,
hidePoweredBy = false,
isPlatformOrEnterprise = false,
}: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -131,7 +136,7 @@ export const EmbedSignDocumentClientPage = ({
}
};
useEffect(() => {
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
try {
@ -144,6 +149,17 @@ export const EmbedSignDocumentClientPage = ({
// Since a recipient can be provided a name we can lock it without requiring
// a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName);
if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled');
}
if (isPlatformOrEnterprise) {
injectCss({
css: data.css,
cssVars: data.cssVars,
});
}
} catch (err) {
console.error(err);
}
@ -176,8 +192,8 @@ export const EmbedSignDocumentClientPage = ({
fieldId: 1,
recipientId: 1,
created: new Date(),
typedSignature: null,
signatureImageAsBase64: signature,
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
typedSignature: signature?.startsWith('data:') ? null : signature,
}}
/>
);
@ -202,7 +218,7 @@ export const EmbedSignDocumentClientPage = ({
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div>
<div className="flex items-center justify-between gap-x-2">
@ -325,10 +341,12 @@ export const EmbedSignDocumentClientPage = ({
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
</div>
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
</div>
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<Logo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
);
};

View File

@ -2,11 +2,14 @@ import { notFound } from 'next/navigation';
import { match } from 'ts-pattern';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentStatus } from '@documenso/prisma/client';
@ -56,6 +59,14 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
return <EmbedPaywall />;
}
const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([
isDocumentPlatform(document),
isUserEnterprise({
userId: document.userId,
teamId: document.teamId ?? undefined,
}),
]);
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
});
@ -74,6 +85,12 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
);
}
const team = document.teamId
? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null)
: null;
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
return (
<SigningProvider
email={recipient.email}
@ -93,6 +110,8 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen
fields={fields}
metadata={document.documentMeta}
isCompleted={document.status === DocumentStatus.COMPLETED}
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isPlatformOrEnterprise={isPlatformDocument || isEnterpriseDocument}
/>
</DocumentAuthProvider>
</SigningProvider>

View File

@ -0,0 +1,20 @@
import { type TCssVarsSchema, toNativeCssVars } from './css-vars';
export const injectCss = (options: { css?: string; cssVars?: TCssVarsSchema }) => {
const { css, cssVars } = options;
if (css) {
const style = document.createElement('style');
style.innerHTML = css;
document.head.appendChild(style);
}
if (cssVars) {
const nativeVars = toNativeCssVars(cssVars);
for (const [key, value] of Object.entries(nativeVars)) {
document.documentElement.style.setProperty(key, value);
}
}
};

View File

@ -6,22 +6,14 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence, motion } from 'framer-motion';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
import {
DocumentVisibilitySelect,
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Form,
FormControl,
@ -37,29 +29,17 @@ export type UpdateTeamDialogProps = {
teamId: number;
teamName: string;
teamUrl: string;
documentVisibility?: DocumentVisibility;
includeSenderDetails?: boolean;
};
const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
name: true,
url: true,
documentVisibility: true,
includeSenderDetails: true,
});
type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
export const UpdateTeamForm = ({
teamId,
teamName,
teamUrl,
documentVisibility,
includeSenderDetails,
}: UpdateTeamDialogProps) => {
export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
const router = useRouter();
const { data: session } = useSession();
const email = session?.user?.email;
const { _ } = useLingui();
const { toast } = useToast();
@ -68,36 +48,17 @@ export const UpdateTeamForm = ({
defaultValues: {
name: teamName,
url: teamUrl,
documentVisibility,
includeSenderDetails,
},
});
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
const includeSenderDetailsCheck = form.watch('includeSenderDetails');
const mapVisibilityToRole = (visibility: DocumentVisibility): DocumentVisibility =>
match(visibility)
.with(DocumentVisibility.ADMIN, () => DocumentVisibility.ADMIN)
.with(DocumentVisibility.MANAGER_AND_ABOVE, () => DocumentVisibility.MANAGER_AND_ABOVE)
.otherwise(() => DocumentVisibility.EVERYONE);
const currentVisibilityRole = mapVisibilityToRole(
documentVisibility ?? DocumentVisibility.EVERYONE,
);
const onFormSubmit = async ({
name,
url,
documentVisibility,
includeSenderDetails,
}: TUpdateTeamFormSchema) => {
const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
try {
await updateTeam({
data: {
name,
url,
documentVisibility,
includeSenderDetails,
},
teamId,
});
@ -111,8 +72,6 @@ export const UpdateTeamForm = ({
form.reset({
name,
url,
documentVisibility,
includeSenderDetails,
});
if (url !== teamUrl) {
@ -186,68 +145,6 @@ export const UpdateTeamForm = ({
)}
/>
<FormField
control={form.control}
name="documentVisibility"
render={({ field }) => (
<FormItem>
<FormLabel className="mt-4 flex flex-row items-center">
<Trans>Default Document Visibility</Trans>
<DocumentVisibilityTooltip />
</FormLabel>
<FormControl>
<DocumentVisibilitySelect
currentMemberRole={currentVisibilityRole}
isTeamSettings={true}
{...field}
onValueChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mb-4">
<FormField
control={form.control}
name="includeSenderDetails"
render={({ field }) => (
<FormItem>
<div className="mt-6 flex flex-row items-center gap-4">
<FormLabel>
<Trans>Send on Behalf of Team</Trans>
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5"
checkClassName="text-white"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
{includeSenderDetailsCheck ? (
<blockquote className="text-foreground/50 text-xs italic">
<Trans>
"{email}" on behalf of "{teamName}" has invited you to sign "example
document".
</Trans>
</blockquote>
) : (
<blockquote className="text-foreground/50 text-xs italic">
<Trans>"{teamUrl}" has invited you to sign "example document".</Trans>
</blockquote>
)}
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end space-x-4">
<AnimatePresence>
{form.formState.isDirty && (

View File

@ -138,6 +138,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
containerClassName={cn('rounded-lg border bg-background')}
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')}
allowTypedSignature={true}
/>
</FormControl>
<FormMessage />