Compare commits

..

13 Commits

Author SHA1 Message Date
3b5c50ed88 v1.13.2 2025-10-30 15:12:40 +11:00
e4e9e749e5 fix: handle custom org limits with member invite 2025-10-30 14:31:58 +11:00
37ae6a86fd fix: embedded direct template recipient auth 2025-10-29 15:22:07 +11:00
88836404d1 v1.13.1 2025-10-24 10:50:25 +11:00
2eebc0e439 feat: add attachments (#2091) 2025-10-23 23:07:10 +11:00
4a3859ec60 feat: signin with microsoft (#1998) 2025-10-22 12:05:11 +11:00
49b792503f fix: query envelope table for openpage stats (#2086) 2025-10-21 12:43:57 +00:00
c3dc76b1b4 feat: add API support for folders (#1967) 2025-10-21 18:22:19 +11:00
daab8461c7 fix: email attachment names (#2085) 2025-10-21 12:59:40 +11:00
1ffc4bd703 v1.13.0 2025-10-21 11:21:04 +11:00
f15c0778b5 fix: authoring token arg and null email settings 2025-10-21 10:42:44 +11:00
06cb8b1f23 fix: email attachment formats (#2077) 2025-10-16 14:16:00 +11:00
7f09ba72f4 feat: add envelopes (#2025)
This PR is handles the changes required to support envelopes. The new
envelope editor/signing page will be hidden during release.

The core changes here is to migrate the documents and templates model to
a centralized envelopes model.

Even though Documents and Templates are removed, from the user
perspective they will still exist as we remap envelopes to documents and
templates.
2025-10-14 21:56:36 +11:00
135 changed files with 3227 additions and 1551 deletions

View File

@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail # https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
# Find documentation on setting up Microsoft OAuth here:
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_WELL_KNOWN="" NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID="" NEXT_PRIVATE_OIDC_CLIENT_ID=""

View File

@ -27,3 +27,33 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
``` ```
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile. Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
## Microsoft OAuth (Azure AD)
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
### Create and configure a new Azure AD application
1. Go to the [Azure Portal](https://portal.azure.com/)
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
3. In the left sidebar, click **App registrations**
4. Click **New registration**
5. Enter a name for your application (e.g., "Documenso")
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
8. Click **Register**
### Configure the application
1. After registration, you'll be taken to the app's overview page
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
3. In the left sidebar, click **Certificates & secrets**
4. Under **Client secrets**, click **New client secret**
5. Add a description and select an expiration period
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
7. In the Documenso environment variables, set the following:
```
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
```

View File

@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
}, },
}); });
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery( const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{ {
parentId: currentFolderId, parentId: currentFolderId,
type: FolderType.DOCUMENT, type: FolderType.DOCUMENT,

View File

@ -127,15 +127,15 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
const distributionMethod = watch('meta.distributionMethod'); const distributionMethod = watch('meta.distributionMethod');
const recipientsMissingSignatureFields = useMemo( const everySignerHasSignature = useMemo(
() => () =>
envelope.recipients.filter( envelope.recipients
(recipient) => .filter((recipient) => recipient.role === RecipientRole.SIGNER)
recipient.role === RecipientRole.SIGNER && .every((recipient) =>
!envelope.fields.some( envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id, (field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
), ),
), ),
[envelope.recipients, envelope.fields], [envelope.recipients, envelope.fields],
); );
@ -178,7 +178,7 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
<Trans>Recipients will be able to sign the document once sent</Trans> <Trans>Recipients will be able to sign the document once sent</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{recipientsMissingSignatureFields.length === 0 ? ( {everySignerHasSignature ? (
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting}> <fieldset disabled={isSubmitting}>
@ -350,8 +350,6 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
</div> </div>
) : ( ) : (
<ul className="text-muted-foreground divide-y"> <ul className="text-muted-foreground divide-y">
{/* Todo: Envelopes - I don't think this section shows up */}
{recipients.length === 0 && ( {recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm"> <li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans> <Trans>No recipients</Trans>
@ -429,13 +427,10 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
<> <>
<Alert variant="warning"> <Alert variant="warning">
<AlertDescription> <AlertDescription>
<Trans>The following signers are missing signature fields:</Trans> <Trans>
Some signers have not been assigned a signature field. Please assign at least 1
<ul className="ml-2 mt-1 list-inside list-disc"> signature field to each signer before proceeding.
{recipientsMissingSignatureFields.map((recipient) => ( </Trans>
<li key={recipient.id}>{recipient.email}</li>
))}
</ul>
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View File

@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
const onFormSubmit = async () => { const onFormSubmit = async () => {
try { try {
await deleteFolder({ await deleteFolder({
id: folder.id, folderId: folder.id,
}); });
onOpenChange(false); onOpenChange(false);

View File

@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
const { toast } = useToast(); const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation(); const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({ const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema), resolver: zodResolver(ZMoveFolderFormSchema),
@ -63,12 +63,16 @@ export const FolderMoveDialog = ({
}); });
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => { const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) return; if (!folder) {
return;
}
try { try {
await moveFolder({ await moveFolder({
id: folder.id, folderId: folder.id,
parentId: targetFolderId || null, data: {
parentId: targetFolderId || null,
},
}); });
onOpenChange(false); onOpenChange(false);

View File

@ -61,8 +61,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation(); const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({ const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema), resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: { defaultValues: {
@ -87,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
try { try {
await updateFolder({ await updateFolder({
id: folder.id, folderId: folder.id,
name: data.name, data: {
visibility: isTeamContext name: data.name,
? (data.visibility ?? DocumentVisibility.EVERYONE) visibility: data.visibility,
: DocumentVisibility.EVERYONE, },
}); });
toast({ toast({
@ -140,38 +138,36 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
)} )}
/> />
{isTeamContext && ( <FormField
<FormField control={form.control}
control={form.control} name="visibility"
name="visibility" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>
<FormLabel> <Trans>Visibility</Trans>
<Trans>Visibility</Trans> </FormLabel>
</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl>
<FormControl> <SelectTrigger>
<SelectTrigger> <SelectValue placeholder={t`Select visibility`} />
<SelectValue placeholder={t`Select visibility`} /> </SelectTrigger>
</SelectTrigger> </FormControl>
</FormControl> <SelectContent>
<SelectContent> <SelectItem value={DocumentVisibility.EVERYONE}>
<SelectItem value={DocumentVisibility.EVERYONE}> <Trans>Everyone</Trans>
<Trans>Everyone</Trans> </SelectItem>
</SelectItem> <SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}> <Trans>Managers and above</Trans>
<Trans>Managers and above</Trans> </SelectItem>
</SelectItem> <SelectItem value={DocumentVisibility.ADMIN}>
<SelectItem value={DocumentVisibility.ADMIN}> <Trans>Admins only</Trans>
<Trans>Admins only</Trans> </SelectItem>
</SelectItem> </SelectContent>
</SelectContent> </Select>
</Select> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
)} />
/>
)}
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>

View File

@ -185,6 +185,10 @@ export const OrganisationMemberInviteDialog = ({
return 'form'; return 'form';
} }
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
return 'form';
}
// This is probably going to screw us over in the future. // This is probably going to screw us over in the future.
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) { if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
return 'alert'; return 'alert';

View File

@ -1,15 +1,40 @@
import { useLingui } from '@lingui/react/macro'; import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { createCallable } from 'react-call'; import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta'; import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
import { Button } from '@documenso/ui/primitives/button';
import { import {
CommandDialog, Dialog,
CommandEmpty, DialogContent,
CommandGroup, DialogDescription,
CommandInput, DialogFooter,
CommandItem, DialogHeader,
CommandList, DialogTitle,
} from '@documenso/ui/primitives/command'; } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const ZSignFieldDropdownFormSchema = z.object({
dropdown: z.string().min(1, { message: msg`Option is required`.id }),
});
type TSignFieldDropdownFormSchema = z.infer<typeof ZSignFieldDropdownFormSchema>;
export type SignFieldDropdownDialogProps = { export type SignFieldDropdownDialogProps = {
fieldMeta: TDropdownFieldMeta; fieldMeta: TDropdownFieldMeta;
@ -21,20 +46,72 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
const values = fieldMeta.values?.map((value) => value.value) ?? []; const values = fieldMeta.values?.map((value) => value.value) ?? [];
const form = useForm<TSignFieldDropdownFormSchema>({
resolver: zodResolver(ZSignFieldDropdownFormSchema),
defaultValues: {
dropdown: fieldMeta.defaultValue,
},
});
return ( return (
<CommandDialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}> <Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<CommandInput placeholder={t`Select an option`} /> <DialogContent position="center">
<CommandList> <DialogHeader>
<CommandEmpty>No results found.</CommandEmpty> <DialogTitle>
<CommandGroup heading={t`Options`}> <Trans>Sign Dropdown Field</Trans>
{values.map((value, i) => ( </DialogTitle>
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
{value} <DialogDescription className="mt-4">
</CommandItem> <Trans>Select a value to sign into the field</Trans>
))} </DialogDescription>
</CommandGroup> </DialogHeader>
</CommandList>
</CommandDialog> <Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="dropdown"
render={({ field }) => (
<FormItem>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="bg-background">
<SelectValue placeholder={t`Select an option`} />
</SelectTrigger>
<SelectContent>
{values.map((value, i) => (
<SelectItem key={i} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
); );
}, },
); );

View File

@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
}, },
}); });
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery( const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{ {
parentId: currentFolderId ?? null, parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE, type: FolderType.TEMPLATE,

View File

@ -9,6 +9,7 @@ export type EmbedAuthenticationRequiredProps = {
email?: string; email?: string;
returnTo: string; returnTo: string;
isGoogleSSOEnabled?: boolean; isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean; isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string; oidcProviderLabel?: string;
}; };
@ -17,6 +18,7 @@ export const EmbedAuthenticationRequired = ({
email, email,
returnTo, returnTo,
// isGoogleSSOEnabled, // isGoogleSSOEnabled,
// isMicrosoftSSOEnabled,
// isOIDCSSOEnabled, // isOIDCSSOEnabled,
// oidcProviderLabel, // oidcProviderLabel,
}: EmbedAuthenticationRequiredProps) => { }: EmbedAuthenticationRequiredProps) => {
@ -37,6 +39,7 @@ export const EmbedAuthenticationRequired = ({
<SignInForm <SignInForm
// Embed currently not supported. // Embed currently not supported.
// isGoogleSSOEnabled={isGoogleSSOEnabled} // isGoogleSSOEnabled={isGoogleSSOEnabled}
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
// isOIDCSSOEnabled={isOIDCSSOEnabled} // isOIDCSSOEnabled={isOIDCSSOEnabled}
// oidcProviderLabel={oidcProviderLabel} // oidcProviderLabel={oidcProviderLabel}
className="mt-4" className="mt-4"

View File

@ -37,6 +37,7 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
import { injectCss } from '~/utils/css-vars'; import { injectCss } from '~/utils/css-vars';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form'; import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { EmbedClientLoading } from './embed-client-loading'; import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed'; import { EmbedDocumentCompleted } from './embed-document-completed';
@ -44,6 +45,7 @@ import { EmbedDocumentFields } from './embed-document-fields';
export type EmbedDirectTemplateClientPageProps = { export type EmbedDirectTemplateClientPageProps = {
token: string; token: string;
envelopeId: string;
updatedAt: Date; updatedAt: Date;
documentData: DocumentData; documentData: DocumentData;
recipient: Recipient; recipient: Recipient;
@ -55,9 +57,10 @@ export type EmbedDirectTemplateClientPageProps = {
export const EmbedDirectTemplateClientPage = ({ export const EmbedDirectTemplateClientPage = ({
token, token,
envelopeId,
updatedAt, updatedAt,
documentData, documentData,
recipient: _recipient, recipient,
fields, fields,
metadata, metadata,
hidePoweredBy = false, hidePoweredBy = false,
@ -321,9 +324,13 @@ export const EmbedDirectTemplateClientPage = ({
} }
return ( return (
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />} {(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
</div>
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}
<div className="flex-1"> <div className="flex-1">

View File

@ -37,6 +37,7 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { injectCss } from '~/utils/css-vars'; import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema'; import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog'; import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
@ -48,6 +49,7 @@ import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentClientPageProps = { export type EmbedSignDocumentClientPageProps = {
token: string; token: string;
documentId: number; documentId: number;
envelopeId: string;
documentData: DocumentData; documentData: DocumentData;
recipient: RecipientWithFields; recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
@ -62,6 +64,7 @@ export type EmbedSignDocumentClientPageProps = {
export const EmbedSignDocumentClientPage = ({ export const EmbedSignDocumentClientPage = ({
token, token,
documentId, documentId,
envelopeId,
documentData, documentData,
recipient, recipient,
fields, fields,
@ -274,15 +277,17 @@ export const EmbedSignDocumentClientPage = ({
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6"> <div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />} {(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
{allowDocumentRejection && ( <div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between"> <DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
{allowDocumentRejection && (
<DocumentSigningRejectDialog <DocumentSigningRejectDialog
documentId={documentId} documentId={documentId}
token={token} token={token}
onRejected={onDocumentRejected} onRejected={onDocumentRejected}
/> />
</div> )}
)} </div>
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row"> <div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */} {/* Viewer */}

View File

@ -45,32 +45,26 @@ const ZDropdownFieldFormSchema = z
.min(1, { .min(1, {
message: msg`Dropdown must have at least one option`.id, message: msg`Dropdown must have at least one option`.id,
}) })
.superRefine((values, ctx) => { .refine(
const seen = new Map<string, number[]>(); // value → indices (data) => {
values.forEach((item, index) => { // Todo: Envelopes - This doesn't work.
const key = item.value; console.log({
if (!seen.has(key)) { data,
seen.set(key, []); });
}
seen.get(key)!.push(index);
});
for (const [key, indices] of seen) { if (data) {
if (indices.length > 1 && key.trim() !== '') { const values = data.map((item) => item.value);
for (const i of indices) { return new Set(values).size === values.length;
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Duplicate values are not allowed`.id,
path: [i, 'value'],
});
}
} }
} return true;
}), },
{
message: 'Duplicate values are not allowed',
},
),
required: z.boolean().optional(), required: z.boolean().optional(),
readOnly: z.boolean().optional(), readOnly: z.boolean().optional(),
}) })
// Todo: Envelopes - This doesn't work
.refine( .refine(
(data) => { (data) => {
// Default value must be one of the available options // Default value must be one of the available options
@ -117,20 +111,7 @@ export const EditorFieldDropdownForm = ({
const addValue = () => { const addValue = () => {
const currentValues = form.getValues('values') || []; const currentValues = form.getValues('values') || [];
const newValues = [...currentValues, { value: 'New option' }];
let newValue = 'New option';
// Iterate to create a unique value
for (let i = 0; i < currentValues.length; i++) {
newValue = `New option ${i + 1}`;
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
newValue = `New option ${i + 1}`;
} else {
break;
}
}
const newValues = [...currentValues, { value: newValue }];
form.setValue('values', newValues); form.setValue('values', newValues);
}; };
@ -146,10 +127,6 @@ export const EditorFieldDropdownForm = ({
newValues.splice(index, 1); newValues.splice(index, 1);
form.setValue('values', newValues); form.setValue('values', newValues);
if (form.getValues('defaultValue') === newValues[index].value) {
form.setValue('defaultValue', undefined);
}
}; };
useEffect(() => { useEffect(() => {
@ -186,26 +163,20 @@ export const EditorFieldDropdownForm = ({
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Select <Select
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
{...field} {...field}
value={field.value === null ? '-1' : field.value} value={field.value}
onValueChange={(value) => field.onChange(value === undefined ? null : value)} onValueChange={(val) => field.onChange(val)}
> >
{/* Todo: Envelopes - THis is cooked */}
<SelectTrigger className="text-muted-foreground bg-background w-full"> <SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Default Value`} /> <SelectValue placeholder={t`Default Value`} />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper"> <SelectContent position="popper">
{(formValues.values || []) {(formValues.values || []).map((item, index) => (
.filter((item) => item.value) <SelectItem key={index} value={item.value || ''}>
.map((item, index) => ( {item.value}
<SelectItem key={index} value={item.value || ''}> </SelectItem>
{item.value} ))}
</SelectItem>
))}
<SelectItem value={'-1'}>
<Trans>None</Trans>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>

View File

@ -1,32 +1,15 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { PlusIcon, Trash } from 'lucide-react'; import { PlusIcon, Trash } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod'; import { z } from 'zod';
import { import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
type TRadioFieldMeta as RadioFieldMeta,
ZRadioFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Separator } from '@documenso/ui/primitives/separator'; import { Separator } from '@documenso/ui/primitives/separator';
import { import {
@ -34,26 +17,31 @@ import {
EditorGenericRequiredField, EditorGenericRequiredField,
} from './editor-field-generic-field-forms'; } from './editor-field-generic-field-forms';
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({ const ZRadioFieldFormSchema = z
label: true, .object({
direction: true, label: z.string().optional(),
values: true, values: z
required: true, .object({ id: z.number(), checked: z.boolean(), value: z.string() })
readOnly: true, .array()
}).refine( .min(1)
(data) => { .optional(),
// There cannot be more than one checked option required: z.boolean().optional(),
if (data.values) { readOnly: z.boolean().optional(),
const checkedValues = data.values.filter((option) => option.checked); })
return checkedValues.length <= 1; .refine(
} (data) => {
return true; // There cannot be more than one checked option
}, if (data.values) {
{ const checkedValues = data.values.filter((option) => option.checked);
message: 'There cannot be more than one checked option', return checkedValues.length <= 1;
path: ['values'], }
}, return true;
); },
{
message: 'There cannot be more than one checked option',
path: ['values'],
},
);
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>; type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
@ -65,12 +53,9 @@ export type EditorFieldRadioFormProps = {
export const EditorFieldRadioForm = ({ export const EditorFieldRadioForm = ({
value = { value = {
type: 'radio', type: 'radio',
direction: 'vertical',
}, },
onValueChange, onValueChange,
}: EditorFieldRadioFormProps) => { }: EditorFieldRadioFormProps) => {
const { t } = useLingui();
const form = useForm<TRadioFieldFormSchema>({ const form = useForm<TRadioFieldFormSchema>({
resolver: zodResolver(ZRadioFieldFormSchema), resolver: zodResolver(ZRadioFieldFormSchema),
mode: 'onChange', mode: 'onChange',
@ -79,7 +64,6 @@ export const EditorFieldRadioForm = ({
values: value.values || [{ id: 1, checked: false, value: 'Default value' }], values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
required: value.required || false, required: value.required || false,
readOnly: value.readOnly || false, readOnly: value.readOnly || false,
direction: value.direction || 'vertical',
}, },
}); });
@ -123,35 +107,7 @@ export const EditorFieldRadioForm = ({
return ( return (
<Form {...form}> <Form {...form}>
<form> <form>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2 pb-2">
<FormField
control={form.control}
name="direction"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Direction</Trans>
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground bg-background w-full">
<SelectValue placeholder={t`Select direction`} />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="vertical">
<Trans>Vertical</Trans>
</SelectItem>
<SelectItem value="horizontal">
<Trans>Horizontal</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<EditorGenericRequiredField formControl={form.control} /> <EditorGenericRequiredField formControl={form.control} />
<EditorGenericReadOnlyField formControl={form.control} /> <EditorGenericReadOnlyField formControl={form.control} />

View File

@ -70,6 +70,7 @@ export type SignInFormProps = {
className?: string; className?: string;
initialEmail?: string; initialEmail?: string;
isGoogleSSOEnabled?: boolean; isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean; isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string; oidcProviderLabel?: string;
returnTo?: string; returnTo?: string;
@ -79,6 +80,7 @@ export const SignInForm = ({
className, className,
initialEmail, initialEmail,
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
oidcProviderLabel, oidcProviderLabel,
returnTo, returnTo,
@ -90,11 +92,14 @@ export const SignInForm = ({
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false); useState(false);
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup' 'totp' | 'backup'
>('totp'); >('totp');
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const redirectPath = useMemo(() => { const redirectPath = useMemo(() => {
@ -271,6 +276,22 @@ export const SignInForm = ({
} }
}; };
const onSignInWithMicrosoftClick = async () => {
try {
await authClient.microsoft.signIn({
redirectPath,
});
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onSignInWithOIDCClick = async () => { const onSignInWithOIDCClick = async () => {
try { try {
await authClient.oidc.signIn({ await authClient.oidc.signIn({
@ -297,6 +318,8 @@ export const SignInForm = ({
if (email) { if (email) {
form.setValue('email', email); form.setValue('email', email);
} }
setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, [form]); }, [form]);
return ( return (
@ -363,42 +386,64 @@ export const SignInForm = ({
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>} {isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button> </Button>
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && ( {!isEmbeddedRedirect && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase"> <>
<div className="bg-border h-px flex-1" /> {hasSocialAuthEnabled && (
<span className="text-muted-foreground bg-transparent"> <div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<Trans>Or continue with</Trans> <div className="bg-border h-px flex-1" />
</span> <span className="text-muted-foreground bg-transparent">
<div className="bg-border h-px flex-1" /> <Trans>Or continue with</Trans>
</div> </span>
)} <div className="bg-border h-px flex-1" />
</div>
)}
{isGoogleSSOEnabled && ( {isGoogleSSOEnabled && (
<Button <Button
type="button" type="button"
size="lg" size="lg"
variant="outline" variant="outline"
className="bg-background text-muted-foreground border" className="bg-background text-muted-foreground border"
disabled={isSubmitting} disabled={isSubmitting}
onClick={onSignInWithGoogleClick} onClick={onSignInWithGoogleClick}
> >
<FcGoogle className="mr-2 h-5 w-5" /> <FcGoogle className="mr-2 h-5 w-5" />
Google Google
</Button> </Button>
)} )}
{isOIDCSSOEnabled && ( {isMicrosoftSSOEnabled && (
<Button <Button
type="button" type="button"
size="lg" size="lg"
variant="outline" variant="outline"
className="bg-background text-muted-foreground border" className="bg-background text-muted-foreground border"
disabled={isSubmitting} disabled={isSubmitting}
onClick={onSignInWithOIDCClick} onClick={onSignInWithMicrosoftClick}
> >
<FaIdCardClip className="mr-2 h-5 w-5" /> <img
{oidcProviderLabel || 'OIDC'} className="mr-2 h-4 w-4"
</Button> alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
Microsoft
</Button>
)}
{isOIDCSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
{oidcProviderLabel || 'OIDC'}
</Button>
)}
</>
)} )}
<Button <Button

View File

@ -66,14 +66,18 @@ export type SignUpFormProps = {
className?: string; className?: string;
initialEmail?: string; initialEmail?: string;
isGoogleSSOEnabled?: boolean; isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean; isOIDCSSOEnabled?: boolean;
returnTo?: string;
}; };
export const SignUpForm = ({ export const SignUpForm = ({
className, className,
initialEmail, initialEmail,
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
returnTo,
}: SignUpFormProps) => { }: SignUpFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@ -84,6 +88,8 @@ export const SignUpForm = ({
const utmSrc = searchParams.get('utm_source') ?? null; const utmSrc = searchParams.get('utm_source') ?? null;
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const form = useForm<TSignUpFormSchema>({ const form = useForm<TSignUpFormSchema>({
values: { values: {
name: '', name: '',
@ -106,7 +112,7 @@ export const SignUpForm = ({
signature, signature,
}); });
await navigate(`/unverified-account`); await navigate(returnTo ? returnTo : '/unverified-account');
toast({ toast({
title: _(msg`Registration Successful`), title: _(msg`Registration Successful`),
@ -148,6 +154,20 @@ export const SignUpForm = ({
} }
}; };
const onSignUpWithMicrosoftClick = async () => {
try {
await authClient.microsoft.signIn();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onSignUpWithOIDCClick = async () => { const onSignUpWithOIDCClick = async () => {
try { try {
await authClient.oidc.signIn(); await authClient.oidc.signIn();
@ -227,7 +247,7 @@ export const SignUpForm = ({
<fieldset <fieldset
className={cn( className={cn(
'flex h-[550px] w-full flex-col gap-y-4', 'flex h-[550px] w-full flex-col gap-y-4',
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]', hasSocialAuthEnabled && 'h-[650px]',
)} )}
disabled={isSubmitting} disabled={isSubmitting}
> >
@ -302,7 +322,7 @@ export const SignUpForm = ({
)} )}
/> />
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && ( {hasSocialAuthEnabled && (
<> <>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase"> <div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" /> <div className="bg-border h-px flex-1" />
@ -330,6 +350,26 @@ export const SignUpForm = ({
</> </>
)} )}
{isMicrosoftSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
</>
)}
{isOIDCSSOEnabled && ( {isOIDCSSOEnabled && (
<> <>
<Button <Button

View File

@ -0,0 +1,79 @@
import { Trans } from '@lingui/react/macro';
import { ExternalLink, PaperclipIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
export type DocumentSigningAttachmentsPopoverProps = {
envelopeId: string;
token: string;
};
export const DocumentSigningAttachmentsPopover = ({
envelopeId,
token,
}: DocumentSigningAttachmentsPopoverProps) => {
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
token,
});
if (!attachments || attachments.data.length === 0) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<PaperclipIcon className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>{' '}
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="start">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Documents and resources related to this envelope.</Trans>
</p>
</div>
<div className="space-y-2">
{attachments?.data.map((attachment) => (
<a
key={attachment.id}
href={attachment.data}
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
>
<div className="flex flex-1 items-center gap-2.5">
<div className="bg-muted rounded p-2">
<PaperclipIcon className="h-4 w-4" />
</div>
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
{attachment.label}
</span>
</div>
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
</a>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
actionVerb = 'sign', actionVerb = 'sign',
onOpenChange, onOpenChange,
}: DocumentSigningAuthAccountProps) => { }: DocumentSigningAuthAccountProps) => {
const { recipient } = useRequiredDocumentSigningAuthContext(); const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
const { t } = useLingui(); const { t } = useLingui();
@ -34,8 +34,10 @@ export const DocumentSigningAuthAccount = ({
try { try {
setIsSigningOut(true); setIsSigningOut(true);
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
await authClient.signOut({ await authClient.signOut({
redirectPath: `/signin#email=${email}`, redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
}); });
} catch { } catch {
setIsSigningOut(false); setIsSigningOut(false);
@ -55,16 +57,28 @@ export const DocumentSigningAuthAccount = ({
<AlertDescription> <AlertDescription>
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? ( {actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
<span> <span>
<Trans> {isDirectTemplate ? (
To mark this document as viewed, you need to be logged in as{' '} <Trans>To mark this document as viewed, you need to be logged in.</Trans>
<strong>{recipient.email}</strong> ) : (
</Trans> <Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
)}
</span> </span>
) : ( ) : (
<span> <span>
{/* Todo: Translate */} {isDirectTemplate ? (
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged <Trans>
in as <strong>{recipient.email}</strong> To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in.
</Trans>
) : (
<Trans>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in as <strong>{recipient.email}</strong>
</Trans>
)}
</span> </span>
)} )}
</AlertDescription> </AlertDescription>

View File

@ -47,7 +47,8 @@ export const DocumentSigningAuthDialog = ({
onOpenChange, onOpenChange,
onReauthFormSubmit, onReauthFormSubmit,
}: DocumentSigningAuthDialogProps) => { }: DocumentSigningAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
useRequiredDocumentSigningAuthContext();
// Filter out EXPLICIT_NONE from available auth types for the chooser // Filter out EXPLICIT_NONE from available auth types for the chooser
const validAuthTypes = availableAuthTypes.filter( const validAuthTypes = availableAuthTypes.filter(
@ -168,7 +169,11 @@ export const DocumentSigningAuthDialog = ({
match({ documentAuthType: selectedAuthType, user }) match({ documentAuthType: selectedAuthType, user })
.with( .with(
{ documentAuthType: DocumentAuth.ACCOUNT }, { documentAuthType: DocumentAuth.ACCOUNT },
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. {
user: P.when(
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
),
}, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />, () => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
) )
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( .with({ documentAuthType: DocumentAuth.PASSKEY }, () => (

View File

@ -37,6 +37,7 @@ export type DocumentSigningAuthContextValue = {
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[]; derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
derivedRecipientActionAuth: TRecipientActionAuthTypes[]; derivedRecipientActionAuth: TRecipientActionAuthTypes[];
isAuthRedirectRequired: boolean; isAuthRedirectRequired: boolean;
isDirectTemplate?: boolean;
isCurrentlyAuthenticating: boolean; isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void; setIsCurrentlyAuthenticating: (_value: boolean) => void;
passkeyData: PasskeyData; passkeyData: PasskeyData;
@ -65,6 +66,7 @@ export const useRequiredDocumentSigningAuthContext = () => {
export interface DocumentSigningAuthProviderProps { export interface DocumentSigningAuthProviderProps {
documentAuthOptions: Envelope['authOptions']; documentAuthOptions: Envelope['authOptions'];
recipient: SigningAuthRecipient; recipient: SigningAuthRecipient;
isDirectTemplate?: boolean;
user?: SessionUser | null; user?: SessionUser | null;
children: React.ReactNode; children: React.ReactNode;
} }
@ -72,6 +74,7 @@ export interface DocumentSigningAuthProviderProps {
export const DocumentSigningAuthProvider = ({ export const DocumentSigningAuthProvider = ({
documentAuthOptions: initialDocumentAuthOptions, documentAuthOptions: initialDocumentAuthOptions,
recipient: initialRecipient, recipient: initialRecipient,
isDirectTemplate = false,
user, user,
children, children,
}: DocumentSigningAuthProviderProps) => { }: DocumentSigningAuthProviderProps) => {
@ -201,6 +204,7 @@ export const DocumentSigningAuthProvider = ({
derivedRecipientAccessAuth, derivedRecipientAccessAuth,
derivedRecipientActionAuth, derivedRecipientActionAuth,
isAuthRedirectRequired, isAuthRedirectRequired,
isDirectTemplate,
isCurrentlyAuthenticating, isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating, setIsCurrentlyAuthenticating,
passkeyData, passkeyData,

View File

@ -32,6 +32,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field'; import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
@ -231,7 +232,13 @@ export const DocumentSigningPageViewV1 = ({
</span> </span>
</div> </div>
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} /> <div className="flex items-center gap-x-4">
<DocumentSigningAttachmentsPopover
envelopeId={document.envelopeId}
token={recipient.token}
/>
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
</div>
</div> </div>
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0"> <div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">

View File

@ -19,6 +19,7 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog'; import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog'; import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector'; import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form'; import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header'; import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
@ -31,8 +32,13 @@ const EnvelopeSignerPageRenderer = lazy(
export const DocumentSigningPageViewV2 = () => { export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { envelope, recipientFields, recipientFieldsRemaining, showPendingFieldTooltip } = const {
useRequiredEnvelopeSigningContext(); envelope,
recipient,
recipientFields,
recipientFieldsRemaining,
showPendingFieldTooltip,
} = useRequiredEnvelopeSigningContext();
return ( return (
<div className="h-screen w-screen bg-gray-50"> <div className="h-screen w-screen bg-gray-50">
@ -83,6 +89,10 @@ export const DocumentSigningPageViewV2 = () => {
<Trans>Actions</Trans> <Trans>Actions</Trans>
</h4> </h4>
<div className="w-full">
<DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} />
</div>
{/* Todo: Allow selecting which document to download and/or the original */} {/* Todo: Allow selecting which document to download and/or the original */}
<Button variant="ghost" size="sm" className="w-full justify-start"> <Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" /> <DownloadCloudIcon className="mr-2 h-4 w-4" />

View File

@ -0,0 +1,241 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentAttachmentsPopoverProps = {
envelopeId: string;
};
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
});
const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const form = useForm<TAttachmentFormSchema>({
resolver: zodResolver(ZAttachmentFormSchema),
defaultValues: {
label: '',
url: '',
},
});
const onSubmit = async (data: TAttachmentFormSchema) => {
try {
await createAttachment({
envelopeId,
data: {
label: data.label,
data: data.url,
},
});
form.reset();
setIsAdding(false);
toast({
title: _(msg`Success`),
description: _(msg`Attachment added successfully.`),
});
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Error`),
description: error.message,
variant: 'destructive',
});
}
};
const onDeleteAttachment = async (id: string) => {
try {
await deleteAttachment({ id });
toast({
title: _(msg`Success`),
description: _(msg`Attachment removed successfully.`),
});
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Error`),
description: error.message,
variant: 'destructive',
});
}
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<Paperclip className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
{attachments && attachments.data.length > 0 && (
<div className="space-y-2">
{attachments?.data.map((attachment) => (
<div
key={attachment.id}
className="border-border flex items-center justify-between rounded-md border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
<a
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
>
{attachment.data}
</a>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void onDeleteAttachment(attachment.id)}
className="ml-2 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{!isAdding && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsAdding(true)}
>
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Attachment</Trans>
</Button>
)}
{isAdding && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={_(msg`Label`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="url" placeholder={_(msg`URL`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
<Trans>Add</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setIsAdding(false);
form.reset();
}}
>
<Trans>Cancel</Trans>
</Button>
</div>
</form>
</Form>
)}
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId, selectedRecipientId,
selectedEnvelopeItemId, selectedEnvelopeItemId,
}: EnvelopeEditorFieldDragDropProps) => { }: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
const { t } = useLingui(); const { t } = useLingui();
@ -262,10 +262,6 @@ export const EnvelopeEditorFieldDragDrop = ({
}; };
}, [onMouseClick, onMouseMove, selectedField]); }, [onMouseClick, onMouseMove, selectedField]);
const selectedRecipientColor = useMemo(() => {
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
}, [selectedRecipientId, getRecipientColorKey]);
return ( return (
<> <>
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5"> <div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
@ -277,23 +273,12 @@ export const EnvelopeEditorFieldDragDrop = ({
onClick={() => setSelectedField(field.type)} onClick={() => setSelectedField(field.type)}
onMouseDown={() => setSelectedField(field.type)} onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined} data-selected={selectedField === field.type ? true : undefined}
className={cn( className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors hover:border-blue-300 hover:bg-blue-50"
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
)}
> >
<p <p
className={cn( className={cn(
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal', 'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
field.className, field.className,
{
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
},
)} )}
> >
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />} {field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
@ -307,7 +292,8 @@ export const EnvelopeEditorFieldDragDrop = ({
<div <div
className={cn( className={cn(
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]', 'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base, // selectedSignerStyles?.base,
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
{ {
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds, '-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds, 'dark:text-black/60': isFieldWithinBounds,

View File

@ -3,12 +3,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import type { FieldType } from '@prisma/client'; import type { FieldType } from '@prisma/client';
import Konva from 'konva'; import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
import type { Transformer } from 'konva/lib/shapes/Transformer'; import type { Transformer } from 'konva/lib/shapes/Transformer';
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react'; import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields'; import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta'; import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
@ -23,10 +26,27 @@ import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { fieldButtonList } from './envelope-editor-fields-drag-drop'; import { fieldButtonList } from './envelope-editor-fields-drag-drop';
export default function EnvelopeEditorFieldsPageRenderer() { export default function EnvelopeEditorFieldsPageRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui(); const { t } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const interactiveTransformer = useRef<Transformer | null>(null); const interactiveTransformer = useRef<Transformer | null>(null);
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]); const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
@ -34,17 +54,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const [isFieldChanging, setIsFieldChanging] = useState(false); const [isFieldChanging, setIsFieldChanging] = useState(false);
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null); const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
const { const viewport = useMemo(
stage, () => page.getViewport({ scale, rotation: rotate }),
pageLayer, [page, rotate, scale],
canvasElement, );
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
@ -55,6 +68,44 @@ export default function EnvelopeEditorFieldsPageRenderer() {
[editorFields.localFields, pageContext.pageNumber], [editorFields.localFields, pageContext.pageNumber],
); );
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const handleResizeOrMove = (event: KonvaEventObject<Event>) => { const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
console.log('Field resized or moved'); console.log('Field resized or moved');
@ -69,7 +120,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const fieldGroup = event.target as Konva.Group; const fieldGroup = event.target as Konva.Group;
const fieldFormId = fieldGroup.id(); const fieldFormId = fieldGroup.id();
// Note: This values are scaled.
const { const {
width: fieldPixelWidth, width: fieldPixelWidth,
height: fieldPixelHeight, height: fieldPixelHeight,
@ -80,8 +130,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
skipShadow: true, skipShadow: true,
}); });
const pageHeight = scaledViewport.height; const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
const pageWidth = scaledViewport.width;
// Calculate x and y as a percentage of the page width and height // Calculate x and y as a percentage of the page width and height
const positionPercentX = (fieldX / pageWidth) * 100; const positionPercentX = (fieldX / pageWidth) * 100;
@ -116,7 +165,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}; };
const renderFieldOnLayer = (field: TLocalField) => { const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) { if (!pageLayer.current || !interactiveTransformer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
return; return;
} }
@ -125,8 +174,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
const isFieldEditable = const isFieldEditable =
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields); recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
const { fieldGroup } = renderField({ const { fieldGroup, isFirstRender } = renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: field.formId, renderId: field.formId,
@ -135,8 +183,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
inserted: false, inserted: false,
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
}, },
pageWidth: unscaledViewport.width, pageWidth: viewport.width,
pageHeight: unscaledViewport.height, pageHeight: viewport.height,
color: getRecipientColorKey(field.recipientId), color: getRecipientColorKey(field.recipientId),
editable: isFieldEditable, editable: isFieldEditable,
mode: 'edit', mode: 'edit',
@ -162,14 +210,24 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}; };
/** /**
* Initialize the Konva page canvas and all fields and interactions. * Create the initial Konva page canvas and initialize all fields and interactions.
*/ */
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Initialize snap guides layer // Initialize snap guides layer
// snapGuideLayer.current = initializeSnapGuides(stage.current); // snapGuideLayer.current = initializeSnapGuides(stage.current);
// Add transformer for resizing and rotating. // Add transformer for resizing and rotating.
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer); interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
@ -177,12 +235,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
// Handle stage click to deselect. // Handle stage click to deselect.
currentStage.on('click', (e) => { stage.current?.on('click', (e) => {
removePendingField(); removePendingField();
if (e.target === stage.current) { if (e.target === stage.current) {
setSelectedFields([]); setSelectedFields([]);
currentPageLayer.batchDraw(); pageLayer.current?.batchDraw();
} }
}); });
@ -209,12 +267,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
setSelectedFields([e.target]); setSelectedFields([e.target]);
}; };
currentStage.on('dragstart', onDragStartOrEnd); stage.current?.on('dragstart', onDragStartOrEnd);
currentStage.on('dragend', onDragStartOrEnd); stage.current?.on('dragend', onDragStartOrEnd);
currentStage.on('transformstart', () => setIsFieldChanging(true)); stage.current?.on('transformstart', () => setIsFieldChanging(true));
currentStage.on('transformend', () => setIsFieldChanging(false)); stage.current?.on('transformend', () => setIsFieldChanging(false));
currentPageLayer.batchDraw(); pageLayer.current.batchDraw();
}; };
/** /**
@ -226,10 +284,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
* - Selecting multiple fields * - Selecting multiple fields
* - Selecting empty area to create fields * - Selecting empty area to create fields
*/ */
const createInteractiveTransformer = ( const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
currentStage: Konva.Stage,
currentPageLayer: Konva.Layer,
) => {
const transformer = new Konva.Transformer({ const transformer = new Konva.Transformer({
rotateEnabled: false, rotateEnabled: false,
keepRatio: false, keepRatio: false,
@ -246,39 +301,36 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}, },
}); });
currentPageLayer.add(transformer); layer.add(transformer);
// Add selection rectangle. // Add selection rectangle.
const selectionRectangle = new Konva.Rect({ const selectionRectangle = new Konva.Rect({
fill: 'rgba(24, 160, 251, 0.3)', fill: 'rgba(24, 160, 251, 0.3)',
visible: false, visible: false,
}); });
currentPageLayer.add(selectionRectangle); layer.add(selectionRectangle);
let x1: number; let x1: number;
let y1: number; let y1: number;
let x2: number; let x2: number;
let y2: number; let y2: number;
currentStage.on('mousedown touchstart', (e) => { stage.on('mousedown touchstart', (e) => {
// do nothing if we mousedown on any shape // do nothing if we mousedown on any shape
if (e.target !== currentStage) { if (e.target !== stage) {
return; return;
} }
const pointerPosition = currentStage.getPointerPosition(); const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) { if (!pointerPosition) {
return; return;
} }
console.log(`pointerPosition.x: ${pointerPosition.x}`); x1 = pointerPosition.x;
console.log(`pointerPosition.y: ${pointerPosition.y}`); y1 = pointerPosition.y;
x2 = pointerPosition.x;
x1 = pointerPosition.x / scale; y2 = pointerPosition.y;
y1 = pointerPosition.y / scale;
x2 = pointerPosition.x / scale;
y2 = pointerPosition.y / scale;
selectionRectangle.setAttrs({ selectionRectangle.setAttrs({
x: x1, x: x1,
@ -289,7 +341,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}); });
}); });
currentStage.on('mousemove touchmove', () => { stage.on('mousemove touchmove', () => {
// do nothing if we didn't start selection // do nothing if we didn't start selection
if (!selectionRectangle.visible()) { if (!selectionRectangle.visible()) {
return; return;
@ -297,14 +349,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.moveToTop(); selectionRectangle.moveToTop();
const pointerPosition = currentStage.getPointerPosition(); const pointerPosition = stage.getPointerPosition();
if (!pointerPosition) { if (!pointerPosition) {
return; return;
} }
x2 = pointerPosition.x / scale; x2 = pointerPosition.x;
y2 = pointerPosition.y / scale; y2 = pointerPosition.y;
selectionRectangle.setAttrs({ selectionRectangle.setAttrs({
x: Math.min(x1, x2), x: Math.min(x1, x2),
@ -314,7 +366,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
}); });
}); });
currentStage.on('mouseup touchend', () => { stage.on('mouseup touchend', () => {
// do nothing if we didn't start selection // do nothing if we didn't start selection
if (!selectionRectangle.visible()) { if (!selectionRectangle.visible()) {
return; return;
@ -325,41 +377,38 @@ export default function EnvelopeEditorFieldsPageRenderer() {
selectionRectangle.visible(false); selectionRectangle.visible(false);
}); });
const stageFieldGroups = currentStage.find('.field-group') || []; const stageFieldGroups = stage.find('.field-group') || [];
const box = selectionRectangle.getClientRect(); const box = selectionRectangle.getClientRect();
const selectedFieldGroups = stageFieldGroups.filter( const selectedFieldGroups = stageFieldGroups.filter(
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(), (shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
); );
setSelectedFields(selectedFieldGroups); setSelectedFields(selectedFieldGroups);
const unscaledBoxWidth = box.width / scale;
const unscaledBoxHeight = box.height / scale;
// Create a field if no items are selected or the size is too small. // Create a field if no items are selected or the size is too small.
if ( if (
selectedFieldGroups.length === 0 && selectedFieldGroups.length === 0 &&
canvasElement.current && canvasElement.current &&
unscaledBoxWidth > MIN_FIELD_WIDTH_PX && box.width > MIN_FIELD_WIDTH_PX &&
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX && box.height > MIN_FIELD_HEIGHT_PX &&
editorFields.selectedRecipient && editorFields.selectedRecipient &&
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
) { ) {
const pendingFieldCreation = new Konva.Rect({ const pendingFieldCreation = new Konva.Rect({
name: 'pending-field-creation', name: 'pending-field-creation',
x: box.x / scale, x: box.x,
y: box.y / scale, y: box.y,
width: unscaledBoxWidth, width: box.width,
height: unscaledBoxHeight, height: box.height,
fill: 'rgba(24, 160, 251, 0.3)', fill: 'rgba(24, 160, 251, 0.3)',
}); });
currentPageLayer.add(pendingFieldCreation); layer.add(pendingFieldCreation);
setPendingFieldCreation(pendingFieldCreation); setPendingFieldCreation(pendingFieldCreation);
} }
}); });
// Clicks should select/deselect shapes // Clicks should select/deselect shapes
currentStage.on('click tap', function (e) { stage.on('click tap', function (e) {
// if we are selecting with rect, do nothing // if we are selecting with rect, do nothing
if ( if (
selectionRectangle.visible() && selectionRectangle.visible() &&
@ -370,7 +419,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
// If empty area clicked, remove all selections // If empty area clicked, remove all selections
if (e.target === stage.current) { if (e.target === stage) {
setSelectedFields([]); setSelectedFields([]);
return; return;
} }
@ -506,13 +555,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
return; return;
} }
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({ const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
width: pixelWidth, width: pixelWidth,
height: pixelHeight, height: pixelHeight,
positionX: pixelX, positionX: pixelX,
positionY: pixelY, positionY: pixelY,
pageWidth: unscaledViewport.width, pageWidth,
pageHeight: unscaledViewport.height, pageHeight,
}); });
editorFields.addField({ editorFields.addField({
@ -546,10 +597,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
} }
return ( return (
<div <div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{selectedKonvaFieldGroups.length > 0 && {selectedKonvaFieldGroups.length > 0 &&
interactiveTransformer.current && interactiveTransformer.current &&
!isFieldChanging && ( !isFieldChanging && (
@ -606,15 +654,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
pendingFieldCreation.y() * scale + left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
pendingFieldCreation.getClientRect().height +
5 +
'px',
left:
pendingFieldCreation.x() * scale +
pendingFieldCreation.getClientRect().width / 2 +
'px',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
zIndex: 50, zIndex: 50,
}} }}
@ -632,15 +673,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
</div> </div>
)} )}
{/* The element Konva will inject it's canvas into. */} <div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
height={scaledViewport.height} width={viewport.width}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@ -22,6 +22,7 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog'; import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog'; import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
@ -131,6 +132,8 @@ export default function EnvelopeEditorHeader() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} />
<EnvelopeEditorSettingsDialog <EnvelopeEditorSettingsDialog
trigger={ trigger={
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">

View File

@ -60,7 +60,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
[FieldType.DROPDOWN]: msg`Dropdown Settings`, [FieldType.DROPDOWN]: msg`Dropdown Settings`,
}; };
export const EnvelopeEditorFieldsPage = () => { export const EnvelopeEditorPageFields = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor(); const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -109,7 +109,7 @@ export const EnvelopeEditorFieldsPage = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} /> <EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */} {/* Document View */}
<div className="mt-4 flex justify-center p-4"> <div className="mt-4 flex justify-center">
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
) : ( ) : (

View File

@ -0,0 +1,176 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
export default function EnvelopeEditorPagePreviewRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo(
() =>
editorFields.localFields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[editorFields.localFields, pageContext.pageNumber],
);
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
renderField({
pageLayer: pageLayer.current,
field: {
renderId: field.formId,
...field,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
},
pageWidth: viewport.width,
pageHeight: viewport.height,
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: 'export',
});
};
/**
* Create the initial Konva page canvas and initialize all fields and interactions.
*/
const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
pageLayer.current.batchDraw();
};
/**
* Render fields when they are added or removed from the localFields.
*/
useEffect(() => {
if (!pageLayer.current || !stage.current) {
return;
}
// If doesn't exist in localFields, destroy it since it's been deleted.
pageLayer.current.find('Group').forEach((group) => {
if (
group.name() === 'field-group' &&
!localPageFields.some((field) => field.formId === group.id())
) {
console.log('Field removed, removing from canvas');
group.destroy();
}
});
// If it exists, rerender.
localPageFields.forEach((field) => {
console.log('Field created/updated, rendering on canvas');
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();
}, [localPageFields]);
if (!currentEnvelopeItem) {
return null;
}
return (
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
<canvas
className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement}
width={viewport.width}
/>
</div>
);
}

View File

@ -13,9 +13,11 @@ import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer')); const EnvelopeEditorPagePreviewRenderer = lazy(
async () => import('./envelope-editor-page-preview-renderer'),
);
export const EnvelopeEditorPreviewPage = () => { export const EnvelopeEditorPagePreview = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor(); const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -49,7 +51,7 @@ export const EnvelopeEditorPreviewPage = () => {
</Alert> </Alert>
{currentEnvelopeItem !== null ? ( {currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} /> <PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
) : ( ) : (
<div className="flex flex-col items-center justify-center py-32"> <div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" /> <FileTextIcon className="text-muted-foreground h-10 w-10" />

View File

@ -41,7 +41,7 @@ type LocalFile = {
isError: boolean; isError: boolean;
}; };
export const EnvelopeEditorUploadPage = () => { export const EnvelopeEditorPageUpload = () => {
const team = useCurrentTeam(); const team = useCurrentTeam();
const { t } = useLingui(); const { t } = useLingui();
@ -224,12 +224,8 @@ export const EnvelopeEditorUploadPage = () => {
<div className="mx-auto max-w-4xl space-y-6 p-8"> <div className="mx-auto max-w-4xl space-y-6 p-8">
<Card backdropBlur={false} className="border"> <Card backdropBlur={false} className="border">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle> <CardTitle>Documents</CardTitle>
<Trans>Documents</Trans> <CardDescription>Add and configure multiple documents</CardDescription>
</CardTitle>
<CardDescription>
<Trans>Add and configure multiple documents</Trans>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@ -39,10 +39,10 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog'; import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
import EnvelopeEditorHeader from './envelope-editor-header'; import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page'; import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page'; import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview'; type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
@ -128,18 +128,6 @@ export default function EnvelopeEditor() {
} }
}; };
// Watch the URL params and setStep if the step changes.
useEffect(() => {
const stepParam = searchParams.get('step') || envelopeEditorSteps[0].id;
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
if (foundStep && foundStep.id !== currentStep) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
navigateToStep(foundStep.id as EnvelopeEditorStep);
}
}, [searchParams]);
useEffect(() => { useEffect(() => {
if (!isAutosaving) { if (!isAutosaving) {
setIsStepLoading(false); setIsStepLoading(false);
@ -163,9 +151,7 @@ export default function EnvelopeEditor() {
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>} {isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs"> <span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
<Trans context="The step counter"> Step {currentStepData.order}/{envelopeEditorSteps.length}
Step {currentStepData.order}/{envelopeEditorSteps.length}
</Trans>
</span> </span>
</h3> </h3>
@ -354,12 +340,13 @@ export default function EnvelopeEditor() {
{/* Main Content - Changes based on current step */} {/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
<AnimateGenericFadeInOut key={currentStep}> <AnimateGenericFadeInOut key={currentStep}>
{match({ currentStep, isStepLoading }) {match({ currentStep, isStepLoading })
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />) .with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />) .with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />) .with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />) .with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
.exhaustive()} .exhaustive()}
</AnimateGenericFadeInOut> </AnimateGenericFadeInOut>
</div> </div>

View File

@ -1,31 +1,41 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import type Konva from 'konva'; import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import type { TEnvelope } from '@documenso/lib/types/envelope'; import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
export default function EnvelopeGenericPageRenderer() { export default function EnvelopeGenericPageRenderer() {
const { t } = useLingui(); const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender(); const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const { const canvasElement = useRef<HTMLCanvasElement>(null);
stage, const konvaContainer = useRef<HTMLDivElement>(null);
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer);
});
const { _className, scale } = pageContext; const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
@ -36,6 +46,44 @@ export default function EnvelopeGenericPageRenderer() {
[fields, pageContext.pageNumber], [fields, pageContext.pageNumber],
); );
// Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => { const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!pageLayer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
@ -43,7 +91,6 @@ export default function EnvelopeGenericPageRenderer() {
} }
renderField({ renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: field.id.toString(), renderId: field.id.toString(),
@ -56,8 +103,8 @@ export default function EnvelopeGenericPageRenderer() {
inserted: false, inserted: false,
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
}, },
pageWidth: unscaledViewport.width, pageWidth: viewport.width,
pageHeight: unscaledViewport.height, pageHeight: viewport.height,
// color: getRecipientColorKey(field.recipientId), // color: getRecipientColorKey(field.recipientId),
color: 'purple', // Todo color: 'purple', // Todo
editable: false, editable: false,
@ -66,15 +113,25 @@ export default function EnvelopeGenericPageRenderer() {
}; };
/** /**
* Initialize the Konva page canvas and all fields and interactions. * Create the initial Konva page canvas and initialize all fields and interactions.
*/ */
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
renderFieldOnLayer(field); renderFieldOnLayer(field);
} }
currentPageLayer.batchDraw(); pageLayer.current.batchDraw();
}; };
/** /**
@ -110,19 +167,14 @@ export default function EnvelopeGenericPageRenderer() {
} }
return ( return (
<div <div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
className="relative w-full" <div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
height={scaledViewport.height} width={viewport.width}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@ -1,12 +1,14 @@
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { type Field, FieldType, type Signature } from '@prisma/client'; import { type Field, FieldType } from '@prisma/client';
import type Konva from 'konva'; import Konva from 'konva';
import type { Layer } from 'konva/lib/Layer';
import type { KonvaEventObject } from 'konva/lib/Node'; import type { KonvaEventObject } from 'konva/lib/Node';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { ZFullFieldSchema } from '@documenso/lib/types/field'; import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items'; import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
@ -26,6 +28,18 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider'; import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerPageRenderer() { export default function EnvelopeSignerPageRenderer() {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { _className, page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const { t } = useLingui(); const { t } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender(); const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@ -44,20 +58,21 @@ export default function EnvelopeSignerPageRenderer() {
setSignature, setSignature,
} = useRequiredEnvelopeSigningContext(); } = useRequiredEnvelopeSigningContext();
const { console.log({ fullName });
stage,
pageLayer,
canvasElement,
konvaContainer,
pageContext,
scaledViewport,
unscaledViewport,
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
const { _className, scale } = pageContext;
const { envelope } = envelopeData; const { envelope } = envelopeData;
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Layer | null>(null);
const viewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
const localPageFields = useMemo( const localPageFields = useMemo(
() => () =>
recipientFields.filter( recipientFields.filter(
@ -67,7 +82,45 @@ export default function EnvelopeSignerPageRenderer() {
[recipientFields, pageContext.pageNumber], [recipientFields, pageContext.pageNumber],
); );
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { // Custom renderer from Konva examples.
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: container } = konvaContainer;
if (!canvas || !container) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
createPageCanvas(container);
});
return () => {
runningTask.cancel();
};
},
[page, viewport],
);
const renderFieldOnLayer = (unparsedField: Field) => {
if (!pageLayer.current) { if (!pageLayer.current) {
console.error('Layer not loaded yet'); console.error('Layer not loaded yet');
return; return;
@ -84,7 +137,6 @@ export default function EnvelopeSignerPageRenderer() {
} }
const { fieldGroup } = renderField({ const { fieldGroup } = renderField({
scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
field: { field: {
renderId: fieldToRender.id.toString(), renderId: fieldToRender.id.toString(),
@ -93,10 +145,9 @@ export default function EnvelopeSignerPageRenderer() {
height: Number(fieldToRender.height), height: Number(fieldToRender.height),
positionX: Number(fieldToRender.positionX), positionX: Number(fieldToRender.positionX),
positionY: Number(fieldToRender.positionY), positionY: Number(fieldToRender.positionY),
signature: unparsedField.signature,
}, },
pageWidth: unscaledViewport.width, pageWidth: viewport.width,
pageHeight: unscaledViewport.height, pageHeight: viewport.height,
color, color,
mode: 'sign', mode: 'sign',
}); });
@ -306,19 +357,29 @@ export default function EnvelopeSignerPageRenderer() {
}; };
/** /**
* Initialize the Konva page canvas and all fields and interactions. * Create the initial Konva page canvas and initialize all fields and interactions.
*/ */
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => { const createPageCanvas = (container: HTMLDivElement) => {
stage.current = new Konva.Stage({
container,
width: viewport.width,
height: viewport.height,
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current?.add(pageLayer.current);
console.log({ console.log({
localPageFields, localPageFields,
}); });
// Render the fields. // Render the fields.
for (const field of localPageFields) { for (const field of localPageFields) {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering renderFieldOnLayer(field);
} }
currentPageLayer.batchDraw(); pageLayer.current.batchDraw();
}; };
/** /**
@ -331,7 +392,7 @@ export default function EnvelopeSignerPageRenderer() {
localPageFields.forEach((field) => { localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas'); console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering renderFieldOnLayer(field);
}); });
pageLayer.current.batchDraw(); pageLayer.current.batchDraw();
@ -342,19 +403,14 @@ export default function EnvelopeSignerPageRenderer() {
} }
return ( return (
<div <div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
className="relative w-full"
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
>
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div> <div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
{/* Canvas the PDF will be rendered on. */}
<canvas <canvas
className={`${_className}__canvas z-0`} className={`${_className}__canvas z-0`}
height={viewport.height}
ref={canvasElement} ref={canvasElement}
height={scaledViewport.height} width={viewport.width}
width={scaledViewport.width}
/> />
</div> </div>
); );

View File

@ -12,6 +12,7 @@ import {
import { Link } from 'react-router'; import { Link } from 'react-router';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema'; import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -28,22 +29,15 @@ import { useCurrentTeam } from '~/providers/team';
export type FolderCardProps = { export type FolderCardProps = {
folder: TFolderWithSubfolders; folder: TFolderWithSubfolders;
onMove: (folder: TFolderWithSubfolders) => void; onMove: (folder: TFolderWithSubfolders) => void;
onPin: (folderId: string) => void;
onUnpin: (folderId: string) => void;
onSettings: (folder: TFolderWithSubfolders) => void; onSettings: (folder: TFolderWithSubfolders) => void;
onDelete: (folder: TFolderWithSubfolders) => void; onDelete: (folder: TFolderWithSubfolders) => void;
}; };
export const FolderCard = ({ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardProps) => {
folder,
onMove,
onPin,
onUnpin,
onSettings,
onDelete,
}: FolderCardProps) => {
const team = useCurrentTeam(); const team = useCurrentTeam();
const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation();
const formatPath = () => { const formatPath = () => {
const rootPath = const rootPath =
folder.type === FolderType.DOCUMENT folder.type === FolderType.DOCUMENT
@ -53,6 +47,15 @@ export const FolderCard = ({
return `${rootPath}/f/${folder.id}`; return `${rootPath}/f/${folder.id}`;
}; };
const updateFolder = async ({ pinned }: { pinned: boolean }) => {
await updateFolderMutation({
folderId: folder.id,
data: {
pinned,
},
});
};
return ( return (
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}> <Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all"> <Card className="hover:bg-muted/50 border-border h-full border transition-all">
@ -112,9 +115,7 @@ export const FolderCard = ({
<Trans>Move</Trans> <Trans>Move</Trans>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={async () => updateFolder({ pinned: !folder.pinned })}>
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
>
<PinIcon className="mr-2 h-4 w-4" /> <PinIcon className="mr-2 h-4 w-4" />
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>} {folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -19,8 +19,6 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card'; import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team'; import { useCurrentTeam } from '~/providers/team';
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
export type FolderGridProps = { export type FolderGridProps = {
type: FolderType; type: FolderType;
parentId: string | null; parentId: string | null;
@ -36,9 +34,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false); const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null); const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({ const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
type, type,
parentId, parentId,
@ -100,7 +95,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<div className="flex gap-4 sm:flex-row sm:justify-end"> <div className="flex gap-4 sm:flex-row sm:justify-end">
{/* Todo: Envelopes - Feature flag */} {/* Todo: Envelopes - Feature flag */}
<EnvelopeUploadButton type={type} folderId={parentId || undefined} /> {/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
{type === FolderType.DOCUMENT ? ( {type === FolderType.DOCUMENT ? (
<DocumentUploadButton /> <DocumentUploadButton />
@ -157,8 +152,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
setFolderToMove(folder); setFolderToMove(folder);
setIsMovingFolder(true); setIsMovingFolder(true);
}} }}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => { onSettings={(folder) => {
setFolderToSettings(folder); setFolderToSettings(folder);
setIsSettingsFolderOpen(true); setIsSettingsFolderOpen(true);
@ -182,8 +175,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
setFolderToMove(folder); setFolderToMove(folder);
setIsMovingFolder(true); setIsMovingFolder(true);
}} }}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => { onSettings={(folder) => {
setFolderToSettings(folder); setFolderToSettings(folder);
setIsSettingsFolderOpen(true); setIsSettingsFolderOpen(true);

View File

@ -9,6 +9,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger'; import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams'; import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { DocumentEditForm } from '~/components/general/document/document-edit-form'; import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status'; import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover'; import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
@ -122,11 +123,13 @@ export default function DocumentEditPage() {
</div> </div>
</div> </div>
{document.useLegacyFieldInsertion && ( <div className="flex items-center gap-x-4">
<div> <DocumentAttachmentsPopover envelopeId={document.envelopeId} />
{document.useLegacyFieldInsertion && (
<LegacyFieldWarningPopover type="document" documentId={document.id} /> <LegacyFieldWarningPopover type="document" documentId={document.id} />
</div> )}
)} </div>
</div> </div>
<DocumentEditForm <DocumentEditForm

View File

@ -42,9 +42,6 @@ export default function DocumentsFoldersPage() {
parentId: null, parentId: null,
}); });
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const navigateToFolder = (folderId?: string | null) => { const navigateToFolder = (folderId?: string | null) => {
const documentsPath = formatDocumentsPath(team.url); const documentsPath = formatDocumentsPath(team.url);
@ -113,8 +110,6 @@ export default function DocumentsFoldersPage() {
setFolderToMove(folder); setFolderToMove(folder);
setIsMovingFolder(true); setIsMovingFolder(true);
}} }}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => { onSettings={(folder) => {
setFolderToSettings(folder); setFolderToSettings(folder);
setIsSettingsFolderOpen(true); setIsSettingsFolderOpen(true);
@ -147,8 +142,6 @@ export default function DocumentsFoldersPage() {
setFolderToMove(folder); setFolderToMove(folder);
setIsMovingFolder(true); setIsMovingFolder(true);
}} }}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => { onSettings={(folder) => {
setFolderToSettings(folder); setFolderToSettings(folder);
setIsSettingsFolderOpen(true); setIsSettingsFolderOpen(true);

View File

@ -8,6 +8,7 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog'; import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover'; import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge'; import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form'; import { TemplateEditForm } from '~/components/general/template/template-edit-form';
@ -87,6 +88,8 @@ export default function TemplateEditPage() {
</div> </div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end"> <div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<DocumentAttachmentsPopover envelopeId={template.envelopeId} />
<TemplateDirectLinkDialog <TemplateDirectLinkDialog
templateId={template.id} templateId={template.id}
directLink={template.directLink} directLink={template.directLink}

View File

@ -42,9 +42,6 @@ export default function TemplatesFoldersPage() {
parentId: null, parentId: null,
}); });
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const navigateToFolder = (folderId?: string | null) => { const navigateToFolder = (folderId?: string | null) => {
const templatesPath = formatTemplatesPath(team.url); const templatesPath = formatTemplatesPath(team.url);
@ -113,8 +110,6 @@ export default function TemplatesFoldersPage() {
setFolderToMove(folder); setFolderToMove(folder);
setIsMovingFolder(true); setIsMovingFolder(true);
}} }}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => { onSettings={(folder) => {
setFolderToSettings(folder); setFolderToSettings(folder);
setIsSettingsFolderOpen(true); setIsSettingsFolderOpen(true);
@ -147,8 +142,6 @@ export default function TemplatesFoldersPage() {
setFolderToMove(folder); setFolderToMove(folder);
setIsMovingFolder(true); setIsMovingFolder(true);
}} }}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => { onSettings={(folder) => {
setFolderToSettings(folder); setFolderToSettings(folder);
setIsSettingsFolderOpen(true); setIsSettingsFolderOpen(true);

View File

@ -95,6 +95,7 @@ export default function DirectTemplatePage() {
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={template.authOptions} documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient} recipient={directTemplateRecipient}
isDirectTemplate={true}
user={user} user={user}
> >
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">

View File

@ -1,13 +1,17 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router'; import { Link, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { import {
IS_GOOGLE_SSO_ENABLED, IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED, IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL, OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth'; } from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { SignInForm } from '~/components/forms/signin'; import { SignInForm } from '~/components/forms/signin';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@ -23,22 +27,45 @@ export async function loader({ request }: Route.LoaderArgs) {
// SSR env variables. // SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED; const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED; const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL; const oidcProviderLabel = OIDC_PROVIDER_LABEL;
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
if (isAuthenticated) { if (isAuthenticated) {
throw redirect('/'); throw redirect(returnTo || '/');
} }
return { return {
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
oidcProviderLabel, oidcProviderLabel,
returnTo,
}; };
} }
export default function SignIn({ loaderData }: Route.ComponentProps) { export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData; const {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
} = loaderData;
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, []);
return ( return (
<div className="w-screen max-w-lg px-4"> <div className="w-screen max-w-lg px-4">
@ -54,15 +81,20 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
<SignInForm <SignInForm
isGoogleSSOEnabled={isGoogleSSOEnabled} isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled} isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel} oidcProviderLabel={oidcProviderLabel}
returnTo={returnTo}
/> />
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && ( {!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm"> <p className="text-muted-foreground mt-6 text-center text-sm">
<Trans> <Trans>
Don't have an account?{' '} Don't have an account?{' '}
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70"> <Link
to={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
className="text-documenso-700 duration-200 hover:opacity-70"
>
Sign up Sign up
</Link> </Link>
</Trans> </Trans>

View File

@ -1,7 +1,12 @@
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth'; import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
} from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { SignUpForm } from '~/components/forms/signup'; import { SignUpForm } from '~/components/forms/signup';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
@ -12,31 +17,40 @@ export function meta() {
return appMetaTags('Sign Up'); return appMetaTags('Sign Up');
} }
export function loader() { export function loader({ request }: Route.LoaderArgs) {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP'); const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
// SSR env variables. // SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED; const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED; const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') { if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
throw redirect('/signin'); throw redirect('/signin');
} }
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
return { return {
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
returnTo,
}; };
} }
export default function SignUp({ loaderData }: Route.ComponentProps) { export default function SignUp({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled } = loaderData; const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
return ( return (
<SignUpForm <SignUpForm
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16" className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
isGoogleSSOEnabled={isGoogleSSOEnabled} isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled} isOIDCSSOEnabled={isOIDCSSOEnabled}
returnTo={returnTo}
/> />
); );
} }

View File

@ -2,6 +2,7 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
import { import {
IS_GOOGLE_SSO_ENABLED, IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED, IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL, OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth'; } from '@documenso/lib/constants/auth';
@ -29,11 +30,13 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
export function loader() { export function loader() {
// SSR env variables. // SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED; const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED; const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL; const oidcProviderLabel = OIDC_PROVIDER_LABEL;
return { return {
isGoogleSSOEnabled, isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled, isOIDCSSOEnabled,
oidcProviderLabel, oidcProviderLabel,
}; };
@ -44,7 +47,8 @@ export default function Layout() {
} }
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {}; const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData || {};
const error = useRouteError(); const error = useRouteError();
@ -53,6 +57,7 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
return ( return (
<EmbedAuthenticationRequired <EmbedAuthenticationRequired
isGoogleSSOEnabled={isGoogleSSOEnabled} isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled} isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel} oidcProviderLabel={oidcProviderLabel}
email={error.data.email} email={error.data.email}

View File

@ -69,7 +69,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw data( throw data(
{ {
type: 'embed-authentication-required', type: 'embed-authentication-required',
email: user?.email,
returnTo: `/embed/direct/${token}`, returnTo: `/embed/direct/${token}`,
}, },
{ {
@ -117,11 +116,13 @@ export default function EmbedDirectTemplatePage() {
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={template.authOptions} documentAuthOptions={template.authOptions}
recipient={recipient} recipient={recipient}
isDirectTemplate={true}
user={user} user={user}
> >
<DocumentSigningRecipientProvider recipient={recipient}> <DocumentSigningRecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage <EmbedDirectTemplateClientPage
token={token} token={token}
envelopeId={template.envelopeId}
updatedAt={template.updatedAt} updatedAt={template.updatedAt}
documentData={template.templateDocumentData} documentData={template.templateDocumentData}
recipient={recipient} recipient={recipient}

View File

@ -164,6 +164,7 @@ export default function EmbedSignDocumentPage() {
<EmbedSignDocumentClientPage <EmbedSignDocumentClientPage
token={token} token={token}
documentId={document.id} documentId={document.id}
envelopeId={document.envelopeId}
documentData={document.documentData} documentData={document.documentData}
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}

View File

@ -4,7 +4,6 @@ import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZBaseEmbedAuthoringSchema = z export const ZBaseEmbedAuthoringSchema = z
.object({ .object({
token: z.string(),
externalId: z.string().optional(), externalId: z.string().optional(),
features: z features: z
.object({ .object({

View File

@ -103,5 +103,5 @@
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"version": "1.12.10" "version": "1.13.2"
} }

View File

@ -0,0 +1 @@
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#F1511B" d="M121.666 121.666H0V0h121.666z"/><path fill="#80CC28" d="M256 121.666H134.335V0H256z"/><path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z"/><path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z"/></svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -1,6 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { rateLimiter } from 'hono-rate-limiter'; import { rateLimiter } from 'hono-rate-limiter';
import { contextStorage } from 'hono/context-storage'; import { contextStorage } from 'hono/context-storage';
import { cors } from 'hono/cors';
import { requestId } from 'hono/request-id'; import { requestId } from 'hono/request-id';
import type { RequestIdVariables } from 'hono/request-id'; import type { RequestIdVariables } from 'hono/request-id';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
@ -83,12 +84,14 @@ app.route('/api/auth', auth);
app.route('/api/files', filesRoute); app.route('/api/files', filesRoute);
// API servers. // API servers.
app.use(`/api/v1/*`, cors());
app.route('/api/v1', tsRestHonoApp); app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler()); app.use('/api/jobs/*', jobsClient.getApiHandler());
app.use('/api/trpc/*', reactRouterTrpcServer); app.use('/api/trpc/*', reactRouterTrpcServer);
// Unstable API server routes. Order matters for these two. // Unstable API server routes. Order matters for these two.
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument)); app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
app.use(`${API_V2_BETA_URL}/*`, cors());
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c)); app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
export default app; export default app;

6
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.12.10", "version": "1.13.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.12.10", "version": "1.13.2",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@ -89,7 +89,7 @@
}, },
"apps/remix": { "apps/remix": {
"name": "@documenso/remix", "name": "@documenso/remix",
"version": "1.12.10", "version": "1.13.2",
"dependencies": { "dependencies": {
"@cantoo/pdf-lib": "^2.3.2", "@cantoo/pdf-lib": "^2.3.2",
"@documenso/api": "*", "@documenso/api": "*",

View File

@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.12.10", "version": "1.13.2",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",

View File

@ -427,6 +427,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
globalAccessAuth: body.authOptions?.globalAccessAuth, globalAccessAuth: body.authOptions?.globalAccessAuth,
globalActionAuth: body.authOptions?.globalActionAuth, globalActionAuth: body.authOptions?.globalActionAuth,
}, },
attachments: body.attachments,
meta: { meta: {
subject: body.meta.subject, subject: body.meta.subject,
message: body.meta.message, message: body.meta.message,
@ -497,6 +498,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription, publicDescription,
type, type,
meta, meta,
attachments,
} = body; } = body;
try { try {
@ -568,6 +570,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription, publicDescription,
}, },
meta, meta,
attachments,
requestMetadata: metadata, requestMetadata: metadata,
}); });
@ -792,6 +795,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
...body.meta, ...body.meta,
title: body.title, title: body.title,
}, },
attachments: body.attachments,
requestMetadata: metadata, requestMetadata: metadata,
}); });

View File

@ -22,6 +22,7 @@ import {
ZRecipientActionAuthTypesSchema, ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email'; import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
extendZodWithOpenApi(z); extendZodWithOpenApi(z);
@ -197,6 +198,15 @@ export const ZCreateDocumentMutationSchema = z.object({
description: 'The globalActionAuth property is only available for Enterprise accounts.', description: 'The globalActionAuth property is only available for Enterprise accounts.',
}), }),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>; export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
@ -262,6 +272,15 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
}) })
.optional(), .optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(), formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
}); });
export type TCreateDocumentFromTemplateMutationSchema = z.infer< export type TCreateDocumentFromTemplateMutationSchema = z.infer<

View File

@ -24,6 +24,7 @@ import {
seedDraftDocument, seedDraftDocument,
seedPendingDocument, seedPendingDocument,
} from '@documenso/prisma/seed/documents'; } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
@ -326,11 +327,6 @@ test.describe('Document API V2', () => {
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) }, data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
}); });
const asdf = await res.json();
console.log({
asdf,
});
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
}); });
@ -407,11 +403,6 @@ test.describe('Document API V2', () => {
headers: { Authorization: `Bearer ${tokenA}` }, headers: { Authorization: `Bearer ${tokenA}` },
}); });
const asdf = await res.json();
console.log({
asdf,
});
expect(res.ok()).toBeTruthy(); expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
}); });
@ -2715,4 +2706,154 @@ test.describe('Document API V2', () => {
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
}); });
}); });
test.describe('Folder list endpoint', () => {
test('should block unauthorized access to folder list endpoint', async ({ request }) => {
await seedBlankFolder(userA, teamA.id);
await seedBlankFolder(userA, teamA.id);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const { data } = await res.json();
expect(data.every((folder: { userId: number }) => folder.userId !== userA.id)).toBe(true);
expect(data.length).toBe(0);
});
test('should allow authorized access to folder list endpoint', async ({ request }) => {
await seedBlankFolder(userA, teamA.id);
await seedBlankFolder(userA, teamA.id);
// Other team folders should not be visible.
await seedBlankFolder(userA, teamB.id);
await seedBlankFolder(userA, teamB.id);
// Other team and user folders should not be visible.
await seedBlankFolder(userB, teamB.id);
await seedBlankFolder(userB, teamB.id);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const { data } = await res.json();
expect(data.length).toBe(2);
expect(data.every((folder: { userId: number }) => folder.userId === userA.id)).toBe(true);
});
});
test.describe('Folder create endpoint', () => {
test('should block unauthorized access to folder create endpoint', async ({ request }) => {
const unauthorizedFolder = await seedBlankFolder(userB, teamB.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
parentId: unauthorizedFolder.id,
name: 'Test Folder',
type: 'DOCUMENT',
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to folder create endpoint', async ({ request }) => {
const authorizedFolder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
parentId: authorizedFolder.id,
name: 'Test Folder',
type: 'DOCUMENT',
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const noParentRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
name: 'Test Folder',
type: 'DOCUMENT',
},
});
expect(noParentRes.ok()).toBeTruthy();
expect(noParentRes.status()).toBe(200);
});
});
test.describe('Folder update endpoint', () => {
test('should block unauthorized access to folder update endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
folderId: folder.id,
data: {
name: 'Updated Folder Name',
},
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to folder update endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
folderId: folder.id,
data: {
name: 'Updated Folder Name',
},
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Folder delete endpoint', () => {
test('should block unauthorized access to folder delete endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: { folderId: folder.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to folder delete endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { folderId: folder.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
}); });

View File

@ -222,6 +222,22 @@ export class AuthClient {
}, },
}; };
public microsoft = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.microsoft.$post({
json: { redirectPath },
});
await this.handleError(response);
const data = await response.json();
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
};
public oidc = { public oidc = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => { signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } }); const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });

View File

@ -26,6 +26,16 @@ export const GoogleAuthOptions: OAuthClientOptions = {
bypassEmailVerification: false, bypassEmailVerification: false,
}; };
export const MicrosoftAuthOptions: OAuthClientOptions = {
id: 'microsoft',
scope: ['openid', 'email', 'profile'],
clientId: env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/microsoft`,
wellKnownUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
bypassEmailVerification: false,
};
export const OidcAuthOptions: OAuthClientOptions = { export const OidcAuthOptions: OAuthClientOptions = {
id: 'oidc', id: 'oidc',
scope: ['openid', 'email', 'profile'], scope: ['openid', 'email', 'profile'],

View File

@ -5,6 +5,7 @@ import { deleteCookie } from 'hono/cookie';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user'; import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { OAuthClientOptions } from '../../config'; import type { OAuthClientOptions } from '../../config';
@ -177,6 +178,12 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
redirectPath = '/'; redirectPath = '/';
} }
if (!isValidReturnTo(redirectPath)) {
redirectPath = '/';
}
redirectPath = normalizeReturnTo(redirectPath) || '/';
const tokens = await oAuthClient.validateAuthorizationCode( const tokens = await oAuthClient.validateAuthorizationCode(
token_endpoint, token_endpoint,
code, code,

View File

@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { GoogleAuthOptions, OidcAuthOptions } from '../config'; import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url'; import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url'; import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
import type { HonoAuthContext } from '../types/context'; import type { HonoAuthContext } from '../types/context';
@ -45,4 +45,11 @@ export const callbackRoute = new Hono<HonoAuthContext>()
/** /**
* Google callback verification. * Google callback verification.
*/ */
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions })); .get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }))
/**
* Microsoft callback verification.
*/
.get('/microsoft', async (c) =>
handleOAuthCallbackUrl({ c, clientOptions: MicrosoftAuthOptions }),
);

View File

@ -2,7 +2,7 @@ import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import { GoogleAuthOptions, OidcAuthOptions } from '../config'; import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url'; import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal'; import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
import type { HonoAuthContext } from '../types/context'; import type { HonoAuthContext } from '../types/context';
@ -24,6 +24,20 @@ export const oauthRoute = new Hono<HonoAuthContext>()
redirectPath, redirectPath,
}); });
}) })
/**
* Microsoft authorize endpoint.
*/
.post('/authorize/microsoft', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
return handleOAuthAuthorizeUrl({
c,
clientOptions: MicrosoftAuthOptions,
redirectPath,
});
})
/** /**
* OIDC authorize endpoint. * OIDC authorize endpoint.
*/ */

View File

@ -136,7 +136,6 @@ export const useEditorFields = ({
const field: TLocalField = { const field: TLocalField = {
...fieldData, ...fieldData,
formId: nanoid(12), formId: nanoid(12),
...restrictFieldPosValues(fieldData),
}; };
append(field); append(field);
@ -166,15 +165,7 @@ export const useEditorFields = ({
const index = localFields.findIndex((field) => field.formId === formId); const index = localFields.findIndex((field) => field.formId === formId);
if (index !== -1) { if (index !== -1) {
const updatedField = { update(index, { ...localFields[index], ...updates });
...localFields[index],
...updates,
};
update(index, {
...updatedField,
...restrictFieldPosValues(updatedField),
});
triggerFieldsUpdate(); triggerFieldsUpdate();
} }
}, },
@ -288,14 +279,3 @@ export const useEditorFields = ({
setSelectedRecipient, setSelectedRecipient,
}; };
}; };
const restrictFieldPosValues = (
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
) => {
return {
positionX: Math.max(0, Math.min(100, field.positionX)),
positionY: Math.max(0, Math.min(100, field.positionY)),
width: Math.max(0, Math.min(100, field.width)),
height: Math.max(0, Math.min(100, field.height)),
};
};

View File

@ -1,105 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import Konva from 'konva';
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
import { usePageContext } from 'react-pdf';
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
export function usePageRenderer(renderFunction: RenderFunction) {
const pageContext = usePageContext();
if (!pageContext) {
throw new Error('Unable to find Page context.');
}
const { page, rotate, scale } = pageContext;
if (!page) {
throw new Error('Attempted to render page canvas, but no page was specified.');
}
const canvasElement = useRef<HTMLCanvasElement>(null);
const konvaContainer = useRef<HTMLDivElement>(null);
const stage = useRef<Konva.Stage | null>(null);
const pageLayer = useRef<Konva.Layer | null>(null);
const unscaledViewport = useMemo(
() => page.getViewport({ scale: 1, rotation: rotate }),
[page, rotate, scale],
);
const scaledViewport = useMemo(
() => page.getViewport({ scale, rotation: rotate }),
[page, rotate, scale],
);
/**
* Render the PDF and create the scaled Konva stage.
*/
useEffect(
function drawPageOnCanvas() {
if (!page) {
return;
}
const { current: canvas } = canvasElement;
const { current: kContainer } = konvaContainer;
if (!canvas || !kContainer) {
return;
}
const renderContext: RenderParameters = {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
viewport: scaledViewport,
};
const cancellable = page.render(renderContext);
const runningTask = cancellable;
cancellable.promise.catch(() => {
// Intentionally empty
});
void cancellable.promise.then(() => {
stage.current = new Konva.Stage({
container: kContainer,
width: scaledViewport.width,
height: scaledViewport.height,
scale: {
x: scale,
y: scale,
},
});
// Create the main layer for interactive elements.
pageLayer.current = new Konva.Layer();
stage.current.add(pageLayer.current);
renderFunction({
stage: stage.current,
pageLayer: pageLayer.current,
});
});
return () => {
runningTask.cancel();
};
},
[page, scaledViewport],
);
return {
canvasElement,
konvaContainer,
stage,
pageLayer,
unscaledViewport,
scaledViewport,
pageContext,
};
}

View File

@ -135,12 +135,7 @@ export const EnvelopeEditorProvider = ({
}); });
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({ const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ recipients }) => { onSuccess: () => {
setEnvelope((prev) => ({
...prev,
recipients,
}));
setAutosaveError(false); setAutosaveError(false);
}, },
onError: (error) => { onError: (error) => {
@ -220,15 +215,14 @@ export const EnvelopeEditorProvider = ({
const getRecipientColorKey = useCallback( const getRecipientColorKey = useCallback(
(recipientId: number) => { (recipientId: number) => {
// Todo: Envelopes - Local recipients
const recipientIndex = envelope.recipients.findIndex( const recipientIndex = envelope.recipients.findIndex(
(recipient) => recipient.id === recipientId, (recipient) => recipient.id === recipientId,
); );
return AVAILABLE_RECIPIENT_COLORS[ return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
];
}, },
[envelope.recipients], [envelope.recipients], // Todo: Envelopes - Local recipients
); );
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery( const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(

View File

@ -6,6 +6,7 @@ export const SALT_ROUNDS = 12;
export const IDENTITY_PROVIDER_NAME: Record<string, string> = { export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso', DOCUMENSO: 'Documenso',
GOOGLE: 'Google', GOOGLE: 'Google',
MICROSOFT: 'Microsoft',
OIDC: 'OIDC', OIDC: 'OIDC',
}; };
@ -13,6 +14,10 @@ export const IS_GOOGLE_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'), env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'),
); );
export const IS_MICROSOFT_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') && env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET'),
);
export const IS_OIDC_SSO_ENABLED = Boolean( export const IS_OIDC_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') && env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
env('NEXT_PRIVATE_OIDC_CLIENT_ID') && env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&

View File

@ -81,12 +81,17 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
const { user: owner } = envelope; const { user: owner } = envelope;
const completedDocumentEmailAttachments = await Promise.all( const completedDocumentEmailAttachments = await Promise.all(
envelope.envelopeItems.map(async (document) => { envelope.envelopeItems.map(async (envelopeItem) => {
const file = await getFileServerSide(document.documentData); const file = await getFileServerSide(envelopeItem.documentData);
// Use the envelope title for version 1, and the envelope item title for version 2.
const fileNameToUse =
envelope.internalVersion === 1 ? envelope.title : envelopeItem.title + '.pdf';
return { return {
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf', filename: fileNameToUse.endsWith('.pdf') ? fileNameToUse : fileNameToUse + '.pdf',
content: Buffer.from(file), content: Buffer.from(file),
contentType: 'application/pdf',
}; };
}), }),
); );

View File

@ -56,7 +56,6 @@ export const sendDocument = async ({
recipients: { recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }], orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
}, },
fields: true,
documentMeta: true, documentMeta: true,
envelopeItems: { envelopeItems: {
select: { select: {
@ -166,16 +165,6 @@ export const sendDocument = async ({
}); });
} }
const fieldsToAutoInsert = [];
// Todo: Envelopes - Handle auto-signing
if (envelope.internalVersion === 2) {
// fieldsToAutoInsert = envelope.fields.filter((field) => !field.inserted);
// if (fieldsToAutoInsert.length > 0) {
// //
// }
}
const updatedEnvelope = await prisma.$transaction(async (tx) => { const updatedEnvelope = await prisma.$transaction(async (tx) => {
if (envelope.status === DocumentStatus.DRAFT) { if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({ await tx.documentAuditLog.create({

View File

@ -0,0 +1,50 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type CreateAttachmentOptions = {
envelopeId: string;
teamId: number;
userId: number;
data: {
label: string;
data: string;
};
};
export const createAttachment = async ({
envelopeId,
teamId,
userId,
data,
}: CreateAttachmentOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.status === DocumentStatus.COMPLETED || envelope.status === DocumentStatus.REJECTED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
return await prisma.envelopeAttachment.create({
data: {
envelopeId,
type: 'link',
...data,
},
});
};

View File

@ -0,0 +1,47 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type DeleteAttachmentOptions = {
id: string;
userId: number;
teamId: number;
};
export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentOptions) => {
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
},
});
if (!attachment) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
await prisma.envelopeAttachment.delete({
where: {
id,
},
});
};

View File

@ -0,0 +1,38 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type FindAttachmentsByEnvelopeIdOptions = {
envelopeId: string;
userId: number;
teamId: number;
};
export const findAttachmentsByEnvelopeId = async ({
envelopeId,
userId,
teamId,
}: FindAttachmentsByEnvelopeIdOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};

View File

@ -0,0 +1,70 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export type FindAttachmentsByTokenOptions = {
envelopeId: string;
token: string;
};
export const findAttachmentsByToken = async ({
envelopeId,
token,
}: FindAttachmentsByTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
recipients: {
some: {
token,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};
export type FindAttachmentsByTeamOptions = {
envelopeId: string;
teamId: number;
};
export const findAttachmentsByTeam = async ({
envelopeId,
teamId,
}: FindAttachmentsByTeamOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
teamId,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};

View File

@ -0,0 +1,49 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdateAttachmentOptions = {
id: string;
userId: number;
teamId: number;
data: { label?: string; data?: string };
};
export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttachmentOptions) => {
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
},
});
if (!attachment) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
return await prisma.envelopeAttachment.update({
where: {
id,
},
data,
});
};

View File

@ -20,6 +20,7 @@ import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-rou
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth'; import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values'; import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import { import {
ZWebhookDocumentSchema, ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload, mapEnvelopeToWebhookDocumentPayload,
@ -58,6 +59,11 @@ export type CreateEnvelopeOptions = {
recipients?: TCreateEnvelopeRequest['recipients']; recipients?: TCreateEnvelopeRequest['recipients'];
folderId?: string; folderId?: string;
}; };
attachments?: Array<{
label: string;
data: string;
type?: TEnvelopeAttachmentType;
}>;
meta?: Partial<Omit<DocumentMeta, 'id'>>; meta?: Partial<Omit<DocumentMeta, 'id'>>;
requestMetadata: ApiRequestMetadata; requestMetadata: ApiRequestMetadata;
}; };
@ -67,6 +73,7 @@ export const createEnvelope = async ({
teamId, teamId,
normalizePdf, normalizePdf,
data, data,
attachments,
meta, meta,
requestMetadata, requestMetadata,
internalVersion, internalVersion,
@ -246,6 +253,15 @@ export const createEnvelope = async ({
})), })),
}, },
}, },
envelopeAttachments: {
createMany: {
data: (attachments || []).map((attachment) => ({
label: attachment.label,
data: attachment.data,
type: attachment.type ?? 'link',
})),
},
},
userId, userId,
teamId, teamId,
authOptions, authOptions,
@ -338,6 +354,7 @@ export const createEnvelope = async ({
fields: true, fields: true,
folder: true, folder: true,
envelopeItems: true, envelopeItems: true,
envelopeAttachments: true,
}, },
}); });

View File

@ -156,11 +156,9 @@ export const setFieldsForDocument = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) { if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta); const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField( const errors = validateNumberField(
String(numberFieldParsedMeta.value), String(numberFieldParsedMeta.value),
numberFieldParsedMeta, numberFieldParsedMeta,
false,
); );
if (errors.length > 0) { if (errors.length > 0) {

View File

@ -1,7 +1,9 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TFolderType } from '../../types/folder-type'; import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type'; import { FolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings'; import { getTeamSettings } from '../team/get-team-settings';
export interface CreateFolderOptions { export interface CreateFolderOptions {
@ -22,6 +24,27 @@ export const createFolder = async ({
// This indirectly verifies whether the user has access to the team. // This indirectly verifies whether the user has access to the team.
const settings = await getTeamSettings({ userId, teamId }); const settings = await getTeamSettings({ userId, teamId });
if (parentId) {
const parentFolder = await prisma.folder.findFirst({
where: {
id: parentId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentFolder.type !== type) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Parent folder type does not match the folder type',
});
}
}
return await prisma.folder.create({ return await prisma.folder.create({
data: { data: {
name, name,

View File

@ -1,6 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams'; import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { getTeamById } from '../team/get-team'; import { getTeamById } from '../team/get-team';
@ -20,6 +21,9 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
teamId, teamId,
userId, userId,
}), }),
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
}, },
}); });
@ -39,7 +43,7 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
return await prisma.folder.delete({ return await prisma.folder.delete({
where: { where: {
id: folderId, id: folder.id,
}, },
}); });
}; };

View File

@ -0,0 +1,117 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
export interface FindFoldersInternalOptions {
userId: number;
teamId: number;
parentId?: string | null;
type?: TFolderType;
}
export const findFoldersInternal = async ({
userId,
teamId,
parentId,
type,
}: FindFoldersInternalOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = {
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
};
const whereClause = {
AND: [
{ parentId },
{
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
},
],
};
try {
const folders = await prisma.folder.findMany({
where: {
...whereClause,
...(type ? { type } : {}),
},
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
folderId: folder.id,
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
folderId: folder.id,
},
}),
prisma.folder.count({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
};

View File

@ -1,9 +1,11 @@
import { EnvelopeType } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type'; import type { TFolderType } from '../../types/folder-type';
import type { FindResultResponse } from '../../types/search-params';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team'; import { getTeamById } from '../team/get-team';
export interface FindFoldersOptions { export interface FindFoldersOptions {
@ -11,102 +13,48 @@ export interface FindFoldersOptions {
teamId: number; teamId: number;
parentId?: string | null; parentId?: string | null;
type?: TFolderType; type?: TFolderType;
page?: number;
perPage?: number;
} }
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => { export const findFolders = async ({
userId,
teamId,
parentId,
type,
page = 1,
perPage = 10,
}: FindFoldersOptions) => {
const team = await getTeamById({ userId, teamId }); const team = await getTeamById({ userId, teamId });
const visibilityFilters = { const whereClause: Prisma.FolderWhereInput = {
parentId,
team: buildTeamWhereQuery({ teamId, userId }),
type,
visibility: { visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
}, },
}; };
const whereClause = { const [data, count] = await Promise.all([
AND: [ prisma.folder.findMany({
{ parentId }, where: whereClause,
{ skip: Math.max(page - 1, 0) * perPage,
OR: [ take: perPage,
{ teamId, ...visibilityFilters }, orderBy: {
{ userId, teamId }, createdAt: 'desc',
],
}, },
], }),
}; prisma.folder.count({
where: whereClause,
}),
]);
try { return {
const folders = await prisma.folder.findMany({ data,
where: { count,
...whereClause, currentPage: Math.max(page, 1),
...(type ? { type } : {}), perPage,
}, totalPages: Math.ceil(count / perPage),
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }], } satisfies FindResultResponse<typeof data>;
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
folderId: folder.id,
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
folderId: folder.id,
},
}),
prisma.folder.count({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
}; };

View File

@ -1,51 +1,30 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility'; import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type'; import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team'; import { getTeamById } from '../team/get-team';
export interface GetFolderByIdOptions { export interface GetFolderByIdOptions {
userId: number; userId: number;
teamId: number; teamId: number;
folderId?: string; folderId: string;
type?: TFolderType; type?: TFolderType;
} }
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => { export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
const team = await getTeamById({ userId, teamId }); const team = await getTeamById({ userId, teamId });
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = {
id: folderId,
...(type ? { type } : {}),
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
};
const folder = await prisma.folder.findFirst({ const folder = await prisma.folder.findFirst({
where: whereClause, where: {
id: folderId,
team: buildTeamWhereQuery({ teamId, userId }),
type,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
},
}); });
if (!folder) { if (!folder) {

View File

@ -1,89 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface MoveFolderOptions {
userId: number;
teamId?: number;
folderId?: string;
parentId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
return await prisma.$transaction(async (tx) => {
const folder = await tx.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (parentId) {
const parentFolder = await tx.folder.findFirst({
where: {
id: parentId,
userId,
teamId,
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await tx.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
return await tx.folder.update({
where: {
id: folderId,
},
data: {
parentId,
},
});
});
};

View File

@ -1,40 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface PinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: true,
},
});
};

View File

@ -1,40 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface UnpinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: false,
},
});
};

View File

@ -1,28 +1,28 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '@documenso/prisma/generated/types'; import type { DocumentVisibility } from '@documenso/prisma/generated/types';
import type { TFolderType } from '../../types/folder-type'; import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { FolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams'; import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface UpdateFolderOptions { export interface UpdateFolderOptions {
userId: number; userId: number;
teamId?: number; teamId: number;
folderId: string; folderId: string;
name: string; data: {
visibility: DocumentVisibility; parentId?: string | null;
type?: TFolderType; name?: string;
visibility?: DocumentVisibility;
pinned?: boolean;
};
} }
export const updateFolder = async ({ export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFolderOptions) => {
userId, const { parentId, name, visibility, pinned } = data;
teamId,
folderId, const team = await getTeamById({ userId, teamId });
name,
visibility,
type,
}: UpdateFolderOptions) => {
const folder = await prisma.folder.findFirst({ const folder = await prisma.folder.findFirst({
where: { where: {
id: folderId, id: folderId,
@ -30,7 +30,9 @@ export const updateFolder = async ({
teamId, teamId,
userId, userId,
}), }),
type, visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
}, },
}); });
@ -40,17 +42,66 @@ export const updateFolder = async ({
}); });
} }
const isTemplateFolder = folder.type === FolderType.TEMPLATE; if (parentId) {
const effectiveVisibility = const parentFolder = await prisma.folder.findFirst({
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility; where: {
id: parentId,
team: buildTeamWhereQuery({ teamId, userId }),
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await prisma.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
return await prisma.folder.update({ return await prisma.folder.update({
where: { where: {
id: folderId, id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
}, },
data: { data: {
name, name,
visibility: effectiveVisibility, visibility,
parentId,
pinned,
}, },
}); });
}; };

View File

@ -3,6 +3,7 @@ import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
import fontkit from '@pdf-lib/fontkit'; import fontkit from '@pdf-lib/fontkit';
import Konva from 'konva'; import Konva from 'konva';
import 'konva/skia-backend'; import 'konva/skia-backend';
import fs from 'node:fs';
import type { Canvas } from 'skia-canvas'; import type { Canvas } from 'skia-canvas';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -85,7 +86,6 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
// Will render onto the layer. // Will render onto the layer.
renderField({ renderField({
scale: 1,
field: { field: {
renderId: field.id.toString(), renderId: field.id.toString(),
...field, ...field,
@ -105,10 +105,10 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
const renderedField = await canvas.toBuffer('svg'); const renderedField = await canvas.toBuffer('svg');
// fs.writeFileSync( fs.writeFileSync(
// `rendered-field-${field.envelopeId}--${field.id}.svg`, `rendered-field-${field.envelopeId}--${field.id}.svg`,
// renderedField.toString('utf-8'), renderedField.toString('utf-8'),
// ); );
// Embed the SVG into the PDF // Embed the SVG into the PDF
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8')); const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));

View File

@ -640,6 +640,23 @@ export const createDocumentFromDirectTemplate = async ({
data: auditLogsToCreate, data: auditLogsToCreate,
}); });
const templateAttachments = await tx.envelopeAttachment.findMany({
where: {
envelopeId: directTemplateEnvelope.id,
},
});
if (templateAttachments.length > 0) {
await tx.envelopeAttachment.createMany({
data: templateAttachments.map((attachment) => ({
envelopeId: createdEnvelope.id,
type: attachment.type,
label: attachment.label,
data: attachment.data,
})),
});
}
// Send email to template owner. // Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, { const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail, recipientName: directRecipientEmail,

View File

@ -91,6 +91,12 @@ export type CreateDocumentFromTemplateOptions = {
envelopeItemId?: string; envelopeItemId?: string;
}[]; }[];
attachments?: Array<{
label: string;
data: string;
type?: 'link';
}>;
/** /**
* Values that will override the predefined values in the template. * Values that will override the predefined values in the template.
*/ */
@ -295,6 +301,7 @@ export const createDocumentFromTemplate = async ({
requestMetadata, requestMetadata,
folderId, folderId,
prefillFields, prefillFields,
attachments,
}: CreateDocumentFromTemplateOptions) => { }: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({ const { envelopeWhereInput } = await getEnvelopeWhereInput({
id, id,
@ -667,6 +674,33 @@ export const createDocumentFromTemplate = async ({
}), }),
}); });
const templateAttachments = await tx.envelopeAttachment.findMany({
where: {
envelopeId: template.id,
},
});
const attachmentsToCreate = [
...templateAttachments.map((attachment) => ({
envelopeId: envelope.id,
type: attachment.type,
label: attachment.label,
data: attachment.data,
})),
...(attachments || []).map((attachment) => ({
envelopeId: envelope.id,
type: attachment.type || 'link',
label: attachment.label,
data: attachment.data,
})),
];
if (attachmentsToCreate.length > 0) {
await tx.envelopeAttachment.createMany({
data: attachmentsToCreate,
});
}
const createdEnvelope = await tx.envelope.findFirst({ const createdEnvelope = await tx.envelope.findFirst({
where: { where: {
id: envelope.id, id: envelope.id,

View File

@ -1,22 +1,23 @@
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma'; import { kyselyPrisma, sql } from '@documenso/prisma';
export const getCompletedDocumentsMonthly = async () => { export const getCompletedDocumentsMonthly = async () => {
const qb = kyselyPrisma.$kysely const qb = kyselyPrisma.$kysely
.selectFrom('Document') .selectFrom('Envelope')
.select(({ fn }) => [ .select(({ fn }) => [
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'), fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
fn.count('id').as('count'), fn.count('id').as('count'),
fn fn
.sum(fn.count('id')) .sum(fn.count('id'))
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner // Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any)) .over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
.as('cume_count'), .as('cume_count'),
]) ])
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`) .where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
.groupBy('month') .groupBy('month')
.orderBy('month', 'desc') .orderBy('month', 'desc')
.limit(12); .limit(12);

View File

@ -127,7 +127,7 @@ export const ZDocumentMetaCreateSchema = z.object({
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(), drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
emailId: z.string().nullish(), emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(), emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.optional(), emailSettings: ZDocumentEmailSettingsSchema.nullish(),
}); });
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>; export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;

View File

@ -0,0 +1,5 @@
import { z } from 'zod';
export const ZEnvelopeAttachmentTypeSchema = z.enum(['link']);
export type TEnvelopeAttachmentType = z.infer<typeof ZEnvelopeAttachmentTypeSchema>;

View File

@ -81,7 +81,6 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
}), }),
) )
.optional(), .optional(),
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
}); });
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>; export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
@ -279,7 +278,6 @@ export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
values: [{ id: 1, checked: false, value: '' }], values: [{ id: 1, checked: false, value: '' }],
required: false, required: false,
readOnly: false, readOnly: false,
direction: 'vertical',
}; };
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = { export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {

View File

@ -12,7 +12,7 @@ export const upsertFieldGroup = (
field: FieldToRender, field: FieldToRender,
options: RenderFieldElementOptions, options: RenderFieldElementOptions,
): Konva.Group => { ): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable, scale } = options; const { pageWidth, pageHeight, pageLayer, editable } = options;
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition( const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field, field,
@ -27,9 +27,6 @@ export const upsertFieldGroup = (
name: 'field-group', name: 'field-group',
}); });
const maxXPosition = (pageWidth - fieldWidth) * scale;
const maxYPosition = (pageHeight - fieldHeight) * scale;
fieldGroup.setAttrs({ fieldGroup.setAttrs({
scaleX: 1, scaleX: 1,
scaleY: 1, scaleY: 1,
@ -37,9 +34,8 @@ export const upsertFieldGroup = (
y: fieldY, y: fieldY,
draggable: editable, draggable: editable,
dragBoundFunc: (pos) => { dragBoundFunc: (pos) => {
const newX = Math.max(0, Math.min(maxXPosition, pos.x)); const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
const newY = Math.max(0, Math.min(maxYPosition, pos.y)); const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
return { x: newX, y: newY }; return { x: newX, y: newY };
}, },
} satisfies Partial<Konva.GroupConfig>); } satisfies Partial<Konva.GroupConfig>);

View File

@ -26,9 +26,8 @@ export type RenderFieldElementOptions = {
pageLayer: Konva.Layer; pageLayer: Konva.Layer;
pageWidth: number; pageWidth: number;
pageHeight: number; pageHeight: number;
mode: 'edit' | 'sign' | 'export'; mode?: 'edit' | 'sign' | 'export';
editable?: boolean; editable?: boolean;
scale: number;
color?: TRecipientColor; color?: TRecipientColor;
}; };
@ -108,11 +107,6 @@ type CalculateMultiItemPositionOptions = {
*/ */
fieldPadding: number; fieldPadding: number;
/**
* The direction of the items.
*/
direction: 'horizontal' | 'vertical';
type: 'checkbox' | 'radio'; type: 'checkbox' | 'radio';
}; };
@ -128,7 +122,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
itemSize, itemSize,
spacingBetweenItemAndText, spacingBetweenItemAndText,
fieldPadding, fieldPadding,
direction,
type, type,
} = options; } = options;
@ -137,39 +130,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
const innerFieldX = fieldPadding; const innerFieldX = fieldPadding;
const innerFieldY = fieldPadding; const innerFieldY = fieldPadding;
if (direction === 'horizontal') {
const itemHeight = innerFieldHeight;
const itemWidth = innerFieldWidth / itemCount;
const y = innerFieldY;
const x = itemIndex * itemWidth + innerFieldX;
let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = x;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') {
itemInputX = x + itemSize / 2;
itemInputY = y + itemHeight / 2;
}
const textX = x + itemSize + spacingBetweenItemAndText;
const textY = y;
// Multiplied by 2 for extra padding on the right hand side of the text and the next item.
const textWidth = itemWidth - itemSize - spacingBetweenItemAndText * 2;
const textHeight = itemHeight;
return {
itemInputX,
itemInputY,
textX,
textY,
textWidth,
textHeight,
};
}
const itemHeight = innerFieldHeight / itemCount; const itemHeight = innerFieldHeight / itemCount;
const y = itemIndex * itemHeight + innerFieldY; const y = itemIndex * itemHeight + innerFieldY;
@ -177,7 +137,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
let itemInputY = y + itemHeight / 2 - itemSize / 2; let itemInputY = y + itemHeight / 2 - itemSize / 2;
let itemInputX = innerFieldX; let itemInputX = innerFieldX;
// We need a little different logic to center the radio circle icon.
if (type === 'radio') { if (type === 'radio') {
itemInputX = innerFieldX + itemSize / 2; itemInputX = innerFieldX + itemSize / 2;
itemInputY = y + itemHeight / 2; itemInputY = y + itemHeight / 2;

View File

@ -1,5 +1,4 @@
import Konva from 'konva'; import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TCheckboxFieldMeta } from '../../types/field-meta'; import type { TCheckboxFieldMeta } from '../../types/field-meta';
@ -22,112 +21,104 @@ export const renderCheckboxFieldElement = (
const fieldGroup = upsertFieldGroup(field, options); const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children and listeners to re-render fresh. // Clear previous children to re-render fresh
fieldGroup.removeChildren(); fieldGroup.removeChildren();
fieldGroup.off('transform');
fieldGroup.add(upsertFieldRect(field, options)); fieldGroup.add(upsertFieldRect(field, options));
if (isFirstRender) {
pageLayer.add(fieldGroup);
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { squareElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: checkboxValues.length,
itemIndex: i,
itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
}
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null; const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
const checkboxValues = checkboxMeta?.values || []; const checkboxValues = checkboxMeta?.values || [];
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect');
if (!fieldRect) {
return;
}
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Todo: Envelopes - check sorting more than 10
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const squares = fieldGroup
.find('.checkbox-square')
.sort((a, b) => a.id().localeCompare(b.id()));
const checkmarks = fieldGroup
.find('.checkbox-checkmark')
.sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
const groupedItems = squares.map((square, i) => ({
squareElement: square,
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => {
const { squareElement, checkmarkElement, textElement } = item;
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: checkboxValues.length,
itemIndex: i,
itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox',
});
squareElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
checkmarkElement.setAttrs({
x: itemInputX,
y: itemInputY,
scaleX: 1,
scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
});
fieldRect.setAttrs({
width: rectWidth,
height: rectHeight,
});
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
checkboxValues.forEach(({ id, value, checked }, index) => { checkboxValues.forEach(({ id, value, checked }, index) => {
const isCheckboxChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => value === field.customText)
.with('export', () => value === field.customText)
.exhaustive();
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({ calculateMultiItemPosition({
fieldWidth, fieldWidth,
@ -137,7 +128,6 @@ export const renderCheckboxFieldElement = (
itemSize: checkboxSize, itemSize: checkboxSize,
spacingBetweenItemAndText: spacingBetweenCheckboxAndText, spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
fieldPadding: checkboxFieldPadding, fieldPadding: checkboxFieldPadding,
direction: checkboxMeta?.direction || 'vertical',
type: 'checkbox', type: 'checkbox',
}); });
@ -166,7 +156,7 @@ export const renderCheckboxFieldElement = (
strokeWidth: 2, strokeWidth: 2,
stroke: '#111827', stroke: '#111827',
points: [3, 8, 7, 12, 13, 4], points: [3, 8, 7, 12, 13, 4],
visible: isCheckboxChecked, visible: checked,
}); });
const text = new Konva.Text({ const text = new Konva.Text({

View File

@ -47,7 +47,6 @@ type RenderFieldOptions = {
*/ */
mode: 'edit' | 'sign' | 'export'; mode: 'edit' | 'sign' | 'export';
scale: number;
editable?: boolean; editable?: boolean;
}; };
@ -57,7 +56,6 @@ export const renderField = ({
pageWidth, pageWidth,
pageHeight, pageHeight,
mode, mode,
scale,
editable, editable,
color, color,
}: RenderFieldOptions) => { }: RenderFieldOptions) => {
@ -68,7 +66,6 @@ export const renderField = ({
mode, mode,
color, color,
editable, editable,
scale,
}; };
return match(field.type) return match(field.type)

View File

@ -1,5 +1,4 @@
import Konva from 'konva'; import Konva from 'konva';
import { match } from 'ts-pattern';
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf'; import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
import type { TRadioFieldMeta } from '../../types/field-meta'; import type { TRadioFieldMeta } from '../../types/field-meta';
@ -27,99 +26,90 @@ export const renderRadioFieldElement = (
fieldGroup.add(upsertFieldRect(field, options)); fieldGroup.add(upsertFieldRect(field, options));
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
const radioValues = radioMeta?.values || [];
if (isFirstRender) { if (isFirstRender) {
pageLayer.add(fieldGroup); pageLayer.add(fieldGroup);
}
fieldGroup.off('transform'); // Handle rescaling items during transforms.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
// Handle rescaling items during transforms. const fieldRect = fieldGroup.findOne('.field-rect');
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
const fieldRect = fieldGroup.findOne('.field-rect'); if (!fieldRect) {
return;
}
if (!fieldRect) { const rectWidth = fieldRect.width() * groupScaleX;
return; const rectHeight = fieldRect.height() * groupScaleY;
}
const rectWidth = fieldRect.width() * groupScaleX; const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
const rectHeight = fieldRect.height() * groupScaleY; const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id())); const groupedItems = circles.map((circle, i) => ({
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id())); circleElement: circle,
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id())); checkmarkElement: checkmarks[i],
textElement: text[i],
}));
const groupedItems = circles.map((circle, i) => ({ groupedItems.forEach((item, i) => {
circleElement: circle, const { circleElement, checkmarkElement, textElement } = item;
checkmarkElement: checkmarks[i],
textElement: text[i],
}));
groupedItems.forEach((item, i) => { const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
const { circleElement, checkmarkElement, textElement } = item; calculateMultiItemPosition({
fieldWidth: rectWidth,
fieldHeight: rectHeight,
itemCount: radioValues.length,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
});
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = circleElement.setAttrs({
calculateMultiItemPosition({ x: itemInputX,
fieldWidth: rectWidth, y: itemInputY,
fieldHeight: rectHeight, scaleX: 1,
itemCount: radioValues.length, scaleY: 1,
itemIndex: i,
itemSize: radioSize,
spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding,
type: 'radio',
direction: radioMeta?.direction || 'vertical',
}); });
circleElement.setAttrs({ checkmarkElement.setAttrs({
x: itemInputX, x: itemInputX,
y: itemInputY, y: itemInputY,
scaleX: 1, scaleX: 1,
scaleY: 1, scaleY: 1,
});
textElement.setAttrs({
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
}); });
checkmarkElement.setAttrs({ fieldRect.width(rectWidth);
x: itemInputX, fieldRect.height(rectHeight);
y: itemInputY,
scaleX: 1, fieldGroup.scale({
scaleY: 1, x: 1,
y: 1,
}); });
textElement.setAttrs({ pageLayer.batchDraw();
x: textX,
y: textY,
scaleX: 1,
scaleY: 1,
width: textWidth,
height: textHeight,
});
}); });
}
fieldRect.width(rectWidth); const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
fieldRect.height(rectHeight); const radioValues = radioMeta?.values || [];
fieldGroup.scale({
x: 1,
y: 1,
});
pageLayer.batchDraw();
});
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight); const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
radioValues.forEach(({ value, checked }, index) => { radioValues.forEach(({ value, checked }, index) => {
const isRadioValueChecked = match(mode)
.with('edit', () => checked)
.with('sign', () => value === field.customText)
.with('export', () => value === field.customText)
.exhaustive();
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } = const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
calculateMultiItemPosition({ calculateMultiItemPosition({
fieldWidth, fieldWidth,
@ -130,7 +120,6 @@ export const renderRadioFieldElement = (
spacingBetweenItemAndText: spacingBetweenRadioAndText, spacingBetweenItemAndText: spacingBetweenRadioAndText,
fieldPadding: radioFieldPadding, fieldPadding: radioFieldPadding,
type: 'radio', type: 'radio',
direction: radioMeta?.direction || 'vertical',
}); });
// Circle which represents the radio button. // Circle which represents the radio button.
@ -155,7 +144,9 @@ export const renderRadioFieldElement = (
y: itemInputY, y: itemInputY,
radius: radioSize / 4, radius: radioSize / 4,
fill: '#111827', fill: '#111827',
visible: isRadioValueChecked, // Todo: Envelopes
visible: value === field.customText,
// visible: checked,
}); });
const text = new Konva.Text({ const text = new Konva.Text({

View File

@ -96,80 +96,77 @@ export const renderSignatureFieldElement = (
const fieldGroup = upsertFieldGroup(field, options); const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children and listeners to re-render fresh. // ABOVE IS GENERIC, EXTRACT IT.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Render the field background and text. // Render the field background and text.
const fieldRect = upsertFieldRect(field, options); const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options); const fieldText = upsertFieldText(field, options);
fieldGroup.add(fieldRect); // Assign elements to group and any listeners that should only be run on initialization.
fieldGroup.add(fieldText); if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
// This is to keep the text inside the field at the same size // This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched. // when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => { fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX(); const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY(); const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized. // Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX); fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY); fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX; const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY; const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping // // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) { // fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight); // ctx.rect(0, 0, rectWidth, rectHeight);
// }); // });
// Update text dimensions // Update text dimensions
fieldText.width(rectWidth); // Account for padding fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight); fieldText.height(rectHeight);
console.log({ console.log({
rectWidth, rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
}); });
// Force Konva to recalculate text layout // Reset the text after transform has ended.
// textInsideField.getTextHeight(); // This forces recalculation fieldGroup.on('transformend', () => {
fieldText.height(); // This forces recalculation fieldText.scaleX(1);
fieldText.scaleY(1);
// fieldGroup.draw(); const rectWidth = fieldRect.width();
fieldGroup.getLayer()?.batchDraw(); const rectHeight = fieldRect.height();
});
// Reset the text after transform has ended. // // Update text group position and clipping
fieldGroup.on('transformend', () => { // fieldGroup.clipFunc(function (ctx) {
fieldText.scaleX(1); // ctx.rect(0, 0, rectWidth, rectHeight);
fieldText.scaleY(1); // });
const rectWidth = fieldRect.width(); // Update text dimensions
const rectHeight = fieldRect.height(); fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// // Update text group position and clipping // Force Konva to recalculate text layout
// fieldGroup.clipFunc(function (ctx) { // textInsideField.getTextHeight(); // This forces recalculation
// ctx.rect(0, 0, rectWidth, rectHeight); fieldText.height(); // This forces recalculation
// });
// Update text dimensions // fieldGroup.draw();
fieldText.width(rectWidth); // Account for padding fieldGroup.getLayer()?.batchDraw();
fieldText.height(rectHeight); });
}
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode. // Handle export mode.
if (mode === 'export') { if (mode === 'export') {

View File

@ -121,80 +121,77 @@ export const renderTextFieldElement = (
const fieldGroup = upsertFieldGroup(field, options); const fieldGroup = upsertFieldGroup(field, options);
// Clear previous children and listeners to re-render fresh. // ABOVE IS GENERIC, EXTRACT IT.
fieldGroup.removeChildren();
fieldGroup.off('transform');
// Assign elements to group and any listeners that should only be run on initialization.
if (isFirstRender) {
pageLayer.add(fieldGroup);
}
// Render the field background and text. // Render the field background and text.
const fieldRect = upsertFieldRect(field, options); const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options); const fieldText = upsertFieldText(field, options);
fieldGroup.add(fieldRect); // Assign elements to group and any listeners that should only be run on initialization.
fieldGroup.add(fieldText); if (isFirstRender) {
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
pageLayer.add(fieldGroup);
// This is to keep the text inside the field at the same size // This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched. // when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => { fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX(); const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY(); const groupScaleY = fieldGroup.scaleY();
// Adjust text scale so it doesn't change while group is resized. // Adjust text scale so it doesn't change while group is resized.
fieldText.scaleX(1 / groupScaleX); fieldText.scaleX(1 / groupScaleX);
fieldText.scaleY(1 / groupScaleY); fieldText.scaleY(1 / groupScaleY);
const rectWidth = fieldRect.width() * groupScaleX; const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY; const rectHeight = fieldRect.height() * groupScaleY;
// // Update text group position and clipping // // Update text group position and clipping
// fieldGroup.clipFunc(function (ctx) { // fieldGroup.clipFunc(function (ctx) {
// ctx.rect(0, 0, rectWidth, rectHeight); // ctx.rect(0, 0, rectWidth, rectHeight);
// }); // });
// Update text dimensions // Update text dimensions
fieldText.width(rectWidth); // Account for padding fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight); fieldText.height(rectHeight);
console.log({ console.log({
rectWidth, rectWidth,
});
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
}); });
// Force Konva to recalculate text layout // Reset the text after transform has ended.
// textInsideField.getTextHeight(); // This forces recalculation fieldGroup.on('transformend', () => {
fieldText.height(); // This forces recalculation fieldText.scaleX(1);
fieldText.scaleY(1);
// fieldGroup.draw(); const rectWidth = fieldRect.width();
fieldGroup.getLayer()?.batchDraw(); const rectHeight = fieldRect.height();
});
// Reset the text after transform has ended. // // Update text group position and clipping
fieldGroup.on('transformend', () => { // fieldGroup.clipFunc(function (ctx) {
fieldText.scaleX(1); // ctx.rect(0, 0, rectWidth, rectHeight);
fieldText.scaleY(1); // });
const rectWidth = fieldRect.width(); // Update text dimensions
const rectHeight = fieldRect.height(); fieldText.width(rectWidth); // Account for padding
fieldText.height(rectHeight);
// // Update text group position and clipping // Force Konva to recalculate text layout
// fieldGroup.clipFunc(function (ctx) { // textInsideField.getTextHeight(); // This forces recalculation
// ctx.rect(0, 0, rectWidth, rectHeight); fieldText.height(); // This forces recalculation
// });
// Update text dimensions // fieldGroup.draw();
fieldText.width(rectWidth); // Account for padding fieldGroup.getLayer()?.batchDraw();
fieldText.height(rectHeight); });
}
// Force Konva to recalculate text layout
// textInsideField.getTextHeight(); // This forces recalculation
fieldText.height(); // This forces recalculation
// fieldGroup.draw();
fieldGroup.getLayer()?.batchDraw();
});
// Handle export mode. // Handle export mode.
if (mode === 'export') { if (mode === 'export') {

View File

@ -0,0 +1,37 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
export const isValidReturnTo = (returnTo?: string) => {
if (!returnTo) {
return false;
}
try {
// Decode if it's URL encoded
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
return false;
}
return true;
} catch {
return false;
}
};
export const normalizeReturnTo = (returnTo?: string) => {
if (!returnTo) {
return undefined;
}
try {
// Decode if it's URL encoded
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
} catch {
return undefined;
}
};

View File

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "EnvelopeAttachment" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"label" TEXT NOT NULL,
"data" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"envelopeId" TEXT NOT NULL,
CONSTRAINT "EnvelopeAttachment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "EnvelopeAttachment" ADD CONSTRAINT "EnvelopeAttachment_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -422,6 +422,8 @@ model Envelope {
documentMetaId String @unique documentMetaId String @unique
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id]) documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
envelopeAttachments EnvelopeAttachment[]
} }
model EnvelopeItem { model EnvelopeItem {
@ -508,6 +510,22 @@ model DocumentMeta {
envelope Envelope? envelope Envelope?
} }
/// @zod.import(["import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';"])
model EnvelopeAttachment {
id String @id @default(cuid())
type String /// [EnvelopeAttachmentType] @zod.custom.use(ZEnvelopeAttachmentTypeSchema)
label String
data String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
envelopeId String
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
}
enum ReadStatus { enum ReadStatus {
NOT_OPENED NOT_OPENED
OPENED OPENED

View File

@ -5,6 +5,7 @@ import type {
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email'; import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values'; import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta'; import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta';
import type { TClaimFlags } from '@documenso/lib/types/subscription'; import type { TClaimFlags } from '@documenso/lib/types/subscription';
@ -23,6 +24,8 @@ declare global {
type RecipientAuthOptions = TRecipientAuthOptions; type RecipientAuthOptions = TRecipientAuthOptions;
type FieldMeta = TFieldMetaNotOptionalSchema; type FieldMeta = TFieldMetaNotOptionalSchema;
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
} }
} }

Some files were not shown because too many files have changed in this diff Show More