Merge branch 'main' into expiry-links

This commit is contained in:
Ephraim Duncan
2024-11-21 18:14:35 +00:00
committed by GitHub
42 changed files with 1063 additions and 439 deletions

View File

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

View File

@ -0,0 +1,237 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Plural, Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import { P, match } from 'ts-pattern';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Field, Recipient } from '@documenso/prisma/client';
import { FieldType } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { Form } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SigningDisclosure } from '~/components/general/signing-disclosure';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider';
const AUTO_SIGNABLE_FIELD_TYPES: string[] = [
FieldType.NAME,
FieldType.INITIALS,
FieldType.EMAIL,
FieldType.DATE,
];
// The action auth types that are not allowed to be auto signed
//
// Reasoning: If the action auth is a passkey or 2FA, it's likely that the owner of the document
// intends on having the user manually sign due to the additional security measures employed for
// other field types.
const NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES: string[] = [
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
];
// The threshold for the number of fields that could be autosigned before displaying the dialog
//
// Reasoning: If there aren't that many fields, it's likely going to be easier to manually sign each one
// while for larger documents with many fields it will be beneficial to sign away the boilerplate fields.
const AUTO_SIGN_THRESHOLD = 5;
export type AutoSignProps = {
recipient: Pick<Recipient, 'id' | 'token'>;
fields: Field[];
};
export const AutoSign = ({ recipient, fields }: AutoSignProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const router = useRouter();
const { email, fullName } = useRequiredSigningContext();
const { derivedRecipientActionAuth } = useRequiredDocumentAuthContext();
const [open, setOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const form = useForm();
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
const autoSignableFields = fields.filter((field) => {
if (field.inserted) {
return false;
}
if (!AUTO_SIGNABLE_FIELD_TYPES.includes(field.type)) {
return false;
}
if (field.type === FieldType.NAME && !fullName) {
return false;
}
if (field.type === FieldType.INITIALS && !fullName) {
return false;
}
if (field.type === FieldType.EMAIL && !email) {
return false;
}
return true;
});
const actionAuthAllowsAutoSign = !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(
derivedRecipientActionAuth ?? '',
);
const onSubmit = async () => {
const results = await Promise.allSettled(
autoSignableFields.map(async (field) => {
const value = match(field.type)
.with(FieldType.NAME, () => fullName)
.with(FieldType.INITIALS, () => extractInitials(fullName))
.with(FieldType.EMAIL, () => email)
.with(FieldType.DATE, () => new Date().toISOString())
.otherwise(() => '');
const authOptions = match(derivedRecipientActionAuth)
.with(DocumentAuth.ACCOUNT, () => ({
type: DocumentAuth.ACCOUNT,
}))
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
.with(null, () => undefined)
.with(
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH),
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
() => 'NOT_SUPPORTED' as const,
)
.exhaustive();
if (authOptions === 'NOT_SUPPORTED') {
throw new Error('Action auth is not supported for auto signing');
}
if (!value) {
throw new Error('No value to sign');
}
return await signFieldWithToken({
token: recipient.token,
fieldId: field.id,
value,
isBase64: false,
authOptions,
});
}),
);
if (results.some((result) => result.status === 'rejected')) {
toast({
title: _(msg`Error`),
description: _(
msg`An error occurred while auto-signing the document, some fields may not be signed. Please review and manually sign any remaining fields.`,
),
duration: 5000,
variant: 'destructive',
});
}
startTransition(() => {
router.refresh();
setOpen(false);
});
};
unsafe_useEffectOnce(() => {
if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) {
setOpen(true);
}
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Automatically sign fields</DialogTitle>
</DialogHeader>
<div className="text-muted-foreground max-w-[50ch]">
<p>
<Trans>
When you sign a document, we can automatically fill in and sign the following fields
using information that has already been provided. You can also manually sign or remove
any automatically signed fields afterwards if you desire.
</Trans>
</p>
<ul className="mt-4 flex list-inside list-disc flex-col gap-y-0.5">
{AUTO_SIGNABLE_FIELD_TYPES.map((fieldType) => (
<li key={fieldType}>
<Trans>{_(FRIENDLY_FIELD_TYPE[fieldType as FieldType])}</Trans>
<span className="pl-2 text-sm">
(
<Plural
value={autoSignableFields.filter((f) => f.type === fieldType).length}
one="1 matching field"
other="# matching fields"
/>
)
</span>
</li>
))}
</ul>
</div>
<SigningDisclosure className="mt-4" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogFooter className="flex w-full flex-1 flex-nowrap gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
}}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="min-w-[6rem]"
loading={form.formState.isSubmitting || isPending}
disabled={!autoSignableFields.length}
>
<Trans>Sign</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@ -144,13 +144,13 @@ export const DateField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Date</Trans>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{localDateString}
</p>
)}

View File

@ -178,7 +178,7 @@ export const DropdownField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200">
<p className="group-hover:text-primary text-muted-foreground flex flex-col items-center justify-center duration-200 ">
<Select value={localChoice} onValueChange={handleSelectItem}>
<SelectTrigger
className={cn(
@ -190,7 +190,7 @@ export const DropdownField = ({
)}
>
<SelectValue
className="text-[clamp(0.625rem,1cqw,0.825rem)]"
className="text-[clamp(0.425rem,25cqw,0.825rem)]"
placeholder={`${_(msg`Select`)}`}
/>
</SelectTrigger>
@ -206,7 +206,7 @@ export const DropdownField = ({
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -122,13 +122,13 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Email</Trans>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -128,13 +128,13 @@ export const InitialsField = ({
)}
{!field.inserted && (
<p className="group-hover:text-primary text-muted-foreground duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary text-muted-foreground text-[clamp(0.425rem,25cqw,0.825rem)] duration-200 group-hover:text-yellow-300">
<Trans>Initials</Trans>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -172,7 +172,7 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -252,14 +252,15 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu
},
)}
>
<span className="flex items-center justify-center gap-x-1 text-sm">
<Hash className="h-4 w-4" /> {fieldDisplayName}
<span className="flex items-center justify-center gap-x-1">
<Hash className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />{' '}
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{fieldDisplayName}</span>
</span>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.625rem,1cqw,0.825rem)] duration-200">
<p className="text-muted-foreground dark:text-background/80 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText}
</p>
)}

View File

@ -191,7 +191,7 @@ export const SignatureField = ({
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground text-xl duration-200 group-hover:text-yellow-300">
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
<Trans>Signature</Trans>
</p>
)}

View File

@ -22,6 +22,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { AutoSign } from './auto-sign';
import { CheckboxField } from './checkbox-field';
import { DateField } from './date-field';
import { DropdownField } from './dropdown-field';
@ -113,6 +114,8 @@ export const SigningPageView = ({
<DocumentReadOnlyFields fields={completedFields} />
<AutoSign recipient={recipient} fields={fields} />
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)

View File

@ -252,14 +252,16 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text
)}
>
<span className="flex items-center justify-center gap-x-1">
<Type />
{fieldDisplayName || <Trans>Text</Trans>}
<Type className="h-[clamp(0.625rem,20cqw,0.925rem)] w-[clamp(0.625rem,20cqw,0.925rem)]" />
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{fieldDisplayName || <Trans>Text</Trans>}
</span>
</span>
</p>
)}
{field.inserted && (
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 duration-200">
<p className="text-muted-foreground dark:text-background/80 flex items-center justify-center gap-x-1 text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
{field.customText.length < 20
? field.customText
: field.customText.substring(0, 15) + '...'}

View File

@ -25,8 +25,6 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
zIndexClass = ZIndexes[zIndex] ?? '';
}
console.log({ type, fallbackText });
switch (type) {
case RecipientStatusType.UNSIGNED:
classes = 'bg-dawn-200 text-dawn-900';