Compare commits

..

1 Commits

Author SHA1 Message Date
8e443b1795 fix: team member invites 2025-02-27 14:21:42 +11:00
222 changed files with 1989 additions and 7070 deletions

23
.github/actions/cache-build/action.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: Cache production build binaries
description: 'Cache or restore if necessary'
inputs:
node_version:
required: false
default: v22.x
runs:
using: 'composite'
steps:
- name: Cache production build
uses: actions/cache@v3
id: production-build-cache
with:
path: |
${{ github.workspace }}/apps/web/.next
**/.turbo/**
**/dist/**
key: prod-build-${{ github.run_id }}-${{ hashFiles('package-lock.json') }}
restore-keys: prod-build-
- run: npm run build
shell: bash

View File

@ -26,8 +26,7 @@ jobs:
- name: Copy env - name: Copy env
run: cp .env.example .env run: cp .env.example .env
- name: Build app - uses: ./.github/actions/cache-build
run: npm run build
build_docker: build_docker:
name: Build Docker Image name: Build Docker Image

29
.github/workflows/clean-cache.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: cleanup caches by a branch
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge

View File

@ -10,7 +10,7 @@ on:
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
runs-on: ubuntu-latest runs-on: ubuntu-22.04
permissions: permissions:
actions: read actions: read
contents: read contents: read
@ -30,8 +30,7 @@ jobs:
- uses: ./.github/actions/node-install - uses: ./.github/actions/node-install
- name: Build app - uses: ./.github/actions/cache-build
run: npm run build
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3 uses: github/codeql-action/init@v3

View File

@ -28,8 +28,7 @@ jobs:
- name: Seed the database - name: Seed the database
run: npm run prisma:seed run: npm run prisma:seed
- name: Build app - uses: ./.github/actions/cache-build
run: npm run build
- name: Run Playwright tests - name: Run Playwright tests
run: npm run ci run: npm run ci

View File

@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
<EmbedDirectTemplate <EmbedDirectTemplate
token={token} token={token}
cssVars={{ cssVars={{
primary: '#0000FF', colorPrimary: '#0000FF',
background: '#F5F5F5', colorBackground: '#F5F5F5',
radius: '8px', borderRadius: '8px',
}} }}
/> />
``` ```

View File

@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
} }
`; `;
const cssVars = { const cssVars = {
primary: '#0000FF', colorPrimary: '#0000FF',
background: '#F5F5F5', colorBackground: '#F5F5F5',
radius: '8px', borderRadius: '8px',
}; };
return ( return (

View File

@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
`} `}
// CSS Variables // CSS Variables
cssVars={{ cssVars={{
primary: '#0000FF', colorPrimary: '#0000FF',
background: '#F5F5F5', colorBackground: '#F5F5F5',
radius: '8px', borderRadius: '8px',
}} }}
// Dark Mode Control // Dark Mode Control
darkModeDisabled={true} darkModeDisabled={true}

View File

@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
} }
`; `;
const cssVars = { const cssVars = {
primary: '#0000FF', colorPrimary: '#0000FF',
background: '#F5F5F5', colorBackground: '#F5F5F5',
radius: '8px', borderRadius: '8px',
}; };
return ( return (

View File

@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
} }
`; `;
const cssVars = { const cssVars = {
primary: '#0000FF', colorPrimary: '#0000FF',
background: '#F5F5F5', colorBackground: '#F5F5F5',
radius: '8px', borderRadius: '8px',
}; };
</script> </script>

View File

@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
} }
`; `;
const cssVars = { const cssVars = {
primary: '#0000FF', colorPrimary: '#0000FF',
background: '#F5F5F5', colorBackground: '#F5F5F5',
radius: '8px', borderRadius: '8px',
}; };
</script> </script>

View File

@ -1,7 +1,7 @@
import { DocumentStatus } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma'; import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => { export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
const qb = kyselyPrisma.$kysely const qb = kyselyPrisma.$kysely

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env sh
# Exit on error. # Exit on error.
set -e set -eo pipefail
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
WEB_APP_DIR="$SCRIPT_DIR/.." WEB_APP_DIR="$SCRIPT_DIR/.."

View File

@ -1,7 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@ -12,171 +9,64 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure'; import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
export type NextSigner = {
name: string;
email: string;
};
type ConfirmationDialogProps = { type ConfirmationDialogProps = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onConfirm: (nextSigner?: NextSigner) => void; onConfirm: () => void;
hasUninsertedFields: boolean; hasUninsertedFields: boolean;
isSubmitting: boolean; isSubmitting: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: NextSigner;
}; };
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export function AssistantConfirmationDialog({ export function AssistantConfirmationDialog({
isOpen, isOpen,
onClose, onClose,
onConfirm, onConfirm,
hasUninsertedFields, hasUninsertedFields,
isSubmitting, isSubmitting,
allowDictateNextSigner = false,
defaultNextSigner,
}: ConfirmationDialogProps) { }: ConfirmationDialogProps) {
const form = useForm<TNextSignerFormSchema>({
resolver: zodResolver(ZNextSignerFormSchema),
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const onOpenChange = () => { const onOpenChange = () => {
if (isSubmitting) { if (isSubmitting) {
return; return;
} }
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
onClose(); onClose();
}; };
const handleSubmit = () => {
// Validate the form and submit it if dictate signer is enabled.
if (allowDictateNextSigner) {
void form.handleSubmit(onConfirm)();
return;
}
onConfirm();
};
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<Form {...form}> <DialogHeader>
<form> <DialogTitle>
<fieldset disabled={isSubmitting} className="border-none p-0"> <Trans>Complete Document</Trans>
<DialogHeader> </DialogTitle>
<DialogTitle> <DialogDescription>
<Trans>Complete Document</Trans> <Trans>
</DialogTitle> Are you sure you want to complete the document? This action cannot be undone. Please
<DialogDescription> ensure that you have completed prefilling all relevant fields before proceeding.
<Trans> </Trans>
Are you sure you want to complete the document? This action cannot be undone. </DialogDescription>
Please ensure that you have completed prefilling all relevant fields before </DialogHeader>
proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="mt-4 flex flex-col gap-4"> <div className="flex flex-col gap-4">
{allowDictateNextSigner && ( <DocumentSigningDisclosure />
<div className="my-2"> </div>
<p className="text-muted-foreground mb-1 text-sm font-semibold">
The next recipient to sign this document will be{' '}
</p>
<div className="flex flex-col gap-4 rounded-xl border p-4 md:flex-row"> <DialogFooter className="mt-4">
<FormField <Button variant="secondary" onClick={onClose} disabled={isSubmitting}>
control={form.control} Cancel
name="name" </Button>
render={({ field }) => ( <Button
<FormItem className="flex-1"> variant={hasUninsertedFields ? 'destructive' : 'default'}
<FormLabel> onClick={onConfirm}
<Trans>Name</Trans> disabled={isSubmitting}
</FormLabel> loading={isSubmitting}
<FormControl> >
<Input {isSubmitting ? 'Submitting...' : hasUninsertedFields ? 'Proceed' : 'Continue'}
{...field} </Button>
className="mt-2" </DialogFooter>
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
</div>
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant={hasUninsertedFields ? 'destructive' : 'default'}
disabled={isSubmitting}
onClick={handleSubmit}
loading={isSubmitting}
>
{hasUninsertedFields ? <Trans>Proceed</Trans> : <Trans>Continue</Trans>}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client'; import { DocumentStatus } from '@prisma/client';
import { P, match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
@ -146,7 +146,7 @@ export const DocumentDeleteDialog = ({
</ul> </ul>
</AlertDescription> </AlertDescription>
)) ))
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => ( .with(DocumentStatus.COMPLETED, () => (
<AlertDescription> <AlertDescription>
<p> <p>
<Trans>By deleting this document, the following will occur:</Trans> <Trans>By deleting this document, the following will occur:</Trans>

View File

@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
import { type DocumentData, type Field, FieldType } from '@prisma/client'; import { type DocumentData, type Field, FieldType } from '@prisma/client';
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
@ -13,10 +13,6 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { import type {
@ -25,11 +21,12 @@ import type {
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
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 { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
@ -68,8 +65,16 @@ export const EmbedDirectTemplateClientPage = ({
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { fullName, email, signature, setFullName, setEmail, setSignature } = const {
useRequiredDocumentSigningContext(); fullName,
email,
signature,
signatureValid,
setFullName,
setEmail,
setSignature,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@ -87,7 +92,7 @@ export const EmbedDirectTemplateClientPage = ({
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields); const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
const [pendingFields, _completedFields] = [ const [pendingFields, _completedFields] = [
localFields.filter((field) => isFieldUnsignedAndRequired(field)), localFields.filter((field) => !field.inserted),
localFields.filter((field) => field.inserted), localFields.filter((field) => field.inserted),
]; ];
@ -105,7 +110,7 @@ export const EmbedDirectTemplateClientPage = ({
const newField: DirectTemplateLocalField = structuredClone({ const newField: DirectTemplateLocalField = structuredClone({
...field, ...field,
customText: payload.value ?? '', customText: payload.value,
inserted: true, inserted: true,
signedValue: payload, signedValue: payload,
}); });
@ -116,10 +121,8 @@ export const EmbedDirectTemplateClientPage = ({
created: new Date(), created: new Date(),
recipientId: 1, recipientId: 1,
fieldId: 1, fieldId: 1,
signatureImageAsBase64: signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
payload.value && payload.value.startsWith('data:') ? payload.value : null, typedSignature: payload.value.startsWith('data:') ? null : payload.value,
typedSignature:
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
} satisfies Signature; } satisfies Signature;
} }
@ -177,7 +180,7 @@ export const EmbedDirectTemplateClientPage = ({
}; };
const onNextFieldClick = () => { const onNextFieldClick = () => {
validateFieldsInserted(pendingFields); validateFieldsInserted(localFields);
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
setIsExpanded(false); setIsExpanded(false);
@ -185,7 +188,11 @@ export const EmbedDirectTemplateClientPage = ({
const onCompleteClick = async () => { const onCompleteClick = async () => {
try { try {
const valid = validateFieldsInserted(pendingFields); if (hasSignatureField && !signatureValid) {
return;
}
const valid = validateFieldsInserted(localFields);
if (!valid) { if (!valid) {
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
@ -198,6 +205,12 @@ export const EmbedDirectTemplateClientPage = ({
directTemplateExternalId = decodeURIComponent(directTemplateExternalId); directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
} }
localFields.forEach((field) => {
if (!field.signedValue) {
throw new Error('Invalid configuration');
}
});
const { const {
documentId, documentId,
token: documentToken, token: documentToken,
@ -208,11 +221,13 @@ export const EmbedDirectTemplateClientPage = ({
directRecipientName: fullName, directRecipientName: fullName,
directRecipientEmail: email, directRecipientEmail: email,
templateUpdatedAt: updatedAt, templateUpdatedAt: updatedAt,
signedFieldValues: localFields signedFieldValues: localFields.map((field) => {
.filter((field) => { if (!field.signedValue) {
return field.signedValue && (isRequiredField(field) || field.inserted); throw new Error('Invalid configuration');
}) }
.map((field) => field.signedValue!),
return field.signedValue;
}),
}); });
if (window.parent) { if (window.parent) {
@ -332,7 +347,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0" className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined} data-expanded={isExpanded || undefined}
> >
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6"> <div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
@ -400,24 +415,40 @@ export const EmbedDirectTemplateClientPage = ({
/> />
</div> </div>
{hasSignatureField && ( <div>
<div> <Label htmlFor="Signature">
<Label htmlFor="Signature"> <Trans>Signature</Trans>
<Trans>Signature</Trans> </Label>
</Label>
<SignaturePadDialog <Card className="mt-2" gradient degrees={-120}>
className="mt-2" <CardContent className="p-0">
disabled={isThrottled || isSubmitting} <SignaturePad
disableAnimation className="h-44 w-full"
value={signature ?? ''} disabled={isThrottled || isSubmitting}
onChange={(v) => setSignature(v ?? '')} defaultValue={signature ?? undefined}
typedSignatureEnabled={metadata?.typedSignatureEnabled} onChange={(value) => {
uploadSignatureEnabled={metadata?.uploadSignatureEnabled} setSignature(value);
drawSignatureEnabled={metadata?.drawSignatureEnabled} }}
/> onValidityChange={(isValid) => {
</div> setSignatureValid(isValid);
)} }}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</div> </div>
</div> </div>

View File

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

View File

@ -54,8 +54,6 @@ export const EmbedDocumentFields = ({
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
typedSignatureEnabled={metadata?.typedSignatureEnabled} typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
/> />
)) ))
.with(FieldType.INITIALS, () => ( .with(FieldType.INITIALS, () => (

View File

@ -1,4 +1,4 @@
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react'; import { useEffect, useId, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
@ -15,19 +15,18 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
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 { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer'; import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo'; import { BrandingLogo } from '~/components/general/branding-logo';
@ -37,7 +36,6 @@ import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-sc
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';
import { DocumentReadOnlyFields } from '../general/document/document-read-only-fields';
import { EmbedClientLoading } from './embed-client-loading'; import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed'; import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields'; import { EmbedDocumentFields } from './embed-document-fields';
@ -49,7 +47,6 @@ export type EmbedSignDocumentClientPageProps = {
documentData: DocumentData; documentData: DocumentData;
recipient: RecipientWithFields; recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
completedFields: DocumentField[];
metadata?: DocumentMeta | TemplateMeta | null; metadata?: DocumentMeta | TemplateMeta | null;
isCompleted?: boolean; isCompleted?: boolean;
hidePoweredBy?: boolean; hidePoweredBy?: boolean;
@ -63,7 +60,6 @@ export const EmbedSignDocumentClientPage = ({
documentData, documentData,
recipient, recipient,
fields, fields,
completedFields,
metadata, metadata,
isCompleted, isCompleted,
hidePoweredBy = false, hidePoweredBy = false,
@ -73,8 +69,15 @@ export const EmbedSignDocumentClientPage = ({
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { fullName, email, signature, setFullName, setSignature } = const {
useRequiredDocumentSigningContext(); fullName,
email,
signature,
signatureValid,
setFullName,
setSignature,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false); const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false); const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@ -89,8 +92,6 @@ export const EmbedSignDocumentClientPage = ({
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false); const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false); const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false); const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
@ -100,26 +101,19 @@ export const EmbedSignDocumentClientPage = ({
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500); const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [pendingFields, _completedFields] = [ const [pendingFields, _completedFields] = [
fields.filter( fields.filter((field) => field.recipientId === recipient.id && !field.inserted),
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
),
fields.filter((field) => field.inserted), fields.filter((field) => field.inserted),
]; ];
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } = const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired),
[fields],
);
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE); const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
const assistantSignersId = useId(); const assistantSignersId = useId();
const onNextFieldClick = () => { const onNextFieldClick = () => {
validateFieldsInserted(fieldsRequiringValidation); validateFieldsInserted(fields);
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
setIsExpanded(false); setIsExpanded(false);
@ -127,7 +121,11 @@ export const EmbedSignDocumentClientPage = ({
const onCompleteClick = async () => { const onCompleteClick = async () => {
try { try {
const valid = validateFieldsInserted(fieldsRequiringValidation); if (hasSignatureField && !signatureValid) {
return;
}
const valid = validateFieldsInserted(fields);
if (!valid) { if (!valid) {
setShowPendingFieldTooltip(true); setShowPendingFieldTooltip(true);
@ -208,7 +206,6 @@ export const EmbedSignDocumentClientPage = ({
// a to be provided by the parent application, unlike direct templates. // a to be provided by the parent application, unlike direct templates.
setIsNameLocked(!!data.lockName); setIsNameLocked(!!data.lockName);
setAllowDocumentRejection(!!data.allowDocumentRejection); setAllowDocumentRejection(!!data.allowDocumentRejection);
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
if (data.darkModeDisabled) { if (data.darkModeDisabled) {
document.documentElement.classList.add('dark-mode-disabled'); document.documentElement.classList.add('dark-mode-disabled');
@ -290,7 +287,7 @@ export const EmbedSignDocumentClientPage = ({
{/* Widget */} {/* Widget */}
<div <div
key={isExpanded ? 'expanded' : 'collapsed'} key={isExpanded ? 'expanded' : 'collapsed'}
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0" className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined} data-expanded={isExpanded || undefined}
> >
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6"> <div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
@ -421,24 +418,40 @@ export const EmbedSignDocumentClientPage = ({
/> />
</div> </div>
{hasSignatureField && ( <div>
<div> <Label htmlFor="Signature">
<Label htmlFor="Signature"> <Trans>Signature</Trans>
<Trans>Signature</Trans> </Label>
</Label>
<SignaturePadDialog <Card className="mt-2" gradient degrees={-120}>
className="mt-2" <CardContent className="p-0">
disabled={isThrottled || isSubmitting} <SignaturePad
disableAnimation className="h-44 w-full"
value={signature ?? ''} disabled={isThrottled || isSubmitting}
onChange={(v) => setSignature(v ?? '')} defaultValue={signature ?? undefined}
typedSignatureEnabled={metadata?.typedSignatureEnabled} onChange={(value) => {
uploadSignatureEnabled={metadata?.uploadSignatureEnabled} setSignature(value);
drawSignatureEnabled={metadata?.drawSignatureEnabled} }}
/> onValidityChange={(isValid) => {
</div> setSignatureValid(isValid);
)} }}
allowTypedSignature={Boolean(
metadata &&
'typedSignatureEnabled' in metadata &&
metadata.typedSignatureEnabled,
)}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</> </>
)} )}
</div> </div>
@ -454,7 +467,9 @@ export const EmbedSignDocumentClientPage = ({
) : ( ) : (
<Button <Button
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'} className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
disabled={isThrottled} disabled={
isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)
}
loading={isSubmitting} loading={isSubmitting}
onClick={() => throttledOnCompleteClick()} onClick={() => throttledOnCompleteClick()}
> >
@ -475,9 +490,6 @@ export const EmbedSignDocumentClientPage = ({
{/* Fields */} {/* Fields */}
<EmbedDocumentFields fields={fields} metadata={metadata} /> <EmbedDocumentFields fields={fields} metadata={metadata} />
{/* Completed fields */}
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
</div> </div>
{!hidePoweredBy && ( {!hidePoweredBy && (

View File

@ -6,10 +6,10 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod'; import { z } from 'zod';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -42,7 +42,7 @@ export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
export const DisableAuthenticatorAppDialog = () => { export const DisableAuthenticatorAppDialog = () => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { refreshSession } = useSession(); const { revalidate } = useRevalidator();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp'); const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
@ -92,7 +92,7 @@ export const DisableAuthenticatorAppDialog = () => {
onCloseTwoFactorDisableDialog(); onCloseTwoFactorDisableDialog();
}); });
await refreshSession(); await revalidate();
} catch (_err) { } catch (_err) {
toast({ toast({
title: _(msg`Unable to disable two-factor authentication`), title: _(msg`Unable to disable two-factor authentication`),

View File

@ -5,12 +5,12 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { renderSVG } from 'uqr'; import { renderSVG } from 'uqr';
import { z } from 'zod'; import { z } from 'zod';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { downloadFile } from '@documenso/lib/client-only/download-file'; import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -48,7 +48,7 @@ export type EnableAuthenticatorAppDialogProps = {
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => { export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
const { refreshSession } = useSession(); const { revalidate } = useRevalidator();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null); const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
@ -74,7 +74,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
try { try {
const data = await authClient.twoFactor.setup(); const data = await authClient.twoFactor.setup();
await refreshSession();
setSetup2FAData(data); setSetup2FAData(data);
} catch (err) { } catch (err) {
@ -93,7 +92,6 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => { const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
try { try {
const data = await authClient.twoFactor.enable({ code: token }); const data = await authClient.twoFactor.enable({ code: token });
await refreshSession();
setRecoveryCodes(data.recoveryCodes); setRecoveryCodes(data.recoveryCodes);
onSuccess?.(); onSuccess?.();
@ -141,6 +139,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) { if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null); setRecoveryCodes(null);
void revalidate();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -19,15 +19,12 @@ import {
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZProfileFormSchema = z.object({ export const ZProfileFormSchema = z.object({
name: z name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
.string() signature: z.string().min(1, 'Signature Pad cannot be empty'),
.trim()
.min(1, { message: msg`Please enter a valid name.`.id }),
signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }),
}); });
export const ZTwoFactorAuthTokenSchema = z.object({ export const ZTwoFactorAuthTokenSchema = z.object({
@ -112,20 +109,22 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
</Label> </Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled /> <Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div> </div>
<FormField <FormField
control={form.control} control={form.control}
name="signature" name="signature"
render={({ field: { onChange, value } }) => ( render={({ field: { onChange } }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Signature</Trans> <Trans>Signature</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<SignaturePadDialog <SignaturePad
className="h-44 w-full"
disabled={isSubmitting} disabled={isSubmitting}
value={value} containerClassName={cn('rounded-lg border bg-background')}
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')} onChange={(v) => onChange(v ?? '')}
allowTypedSignature={true}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -135,7 +134,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
</fieldset> </fieldset>
<Button type="submit" loading={isSubmitting} className="self-end"> <Button type="submit" loading={isSubmitting} className="self-end">
<Trans>Update profile</Trans> {isSubmitting ? <Trans>Updating profile...</Trans> : <Trans>Update profile</Trans>}
</Button> </Button>
</form> </form>
</Form> </Form>

View File

@ -30,7 +30,7 @@ import {
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton'; import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
@ -353,15 +353,16 @@ export const SignUpForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="signature" name="signature"
render={({ field: { onChange, value } }) => ( render={({ field: { onChange } }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans>Sign Here</Trans> <Trans>Sign Here</Trans>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<SignaturePadDialog <SignaturePad
className="h-36 w-full"
disabled={isSubmitting} disabled={isSubmitting}
value={value} containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')} onChange={(v) => onChange(v ?? '')}
/> />
</FormControl> </FormControl>
@ -530,27 +531,6 @@ export const SignUpForm = ({
</div> </div>
</form> </form>
</Form> </Form>
<p className="text-muted-foreground mt-6 text-xs">
<Trans>
By proceeding, you agree to our{' '}
<Link
to="https://documen.so/terms"
target="_blank"
className="text-documenso-700 duration-200 hover:opacity-70"
>
Terms of Service
</Link>{' '}
and{' '}
<Link
to="https://documen.so/privacy"
target="_blank"
className="text-documenso-700 duration-200 hover:opacity-70"
>
Privacy Policy
</Link>
.
</Trans>
</p>
</div> </div>
</div> </div>
); );

View File

@ -308,7 +308,7 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
<div className="flex flex-row justify-end space-x-4"> <div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}> <Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans> <Trans>Save</Trans>
</Button> </Button>
</div> </div>
</fieldset> </fieldset>

View File

@ -8,15 +8,12 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
import { import {
SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode, isValidLanguageCode,
} from '@documenso/lib/constants/i18n'; } from '@documenso/lib/constants/i18n';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { Alert } from '@documenso/ui/primitives/alert'; import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@ -26,9 +23,7 @@ import {
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -43,10 +38,8 @@ const ZTeamDocumentPreferencesFormSchema = z.object({
documentVisibility: z.nativeEnum(DocumentVisibility), documentVisibility: z.nativeEnum(DocumentVisibility),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES), documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
includeSenderDetails: z.boolean(), includeSenderDetails: z.boolean(),
typedSignatureEnabled: z.boolean(),
includeSigningCertificate: z.boolean(), includeSigningCertificate: z.boolean(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
}); });
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>; type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
@ -76,8 +69,8 @@ export const TeamDocumentPreferencesForm = ({
? settings?.documentLanguage ? settings?.documentLanguage
: 'en', : 'en',
includeSenderDetails: settings?.includeSenderDetails ?? false, includeSenderDetails: settings?.includeSenderDetails ?? false,
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
includeSigningCertificate: settings?.includeSigningCertificate ?? true, includeSigningCertificate: settings?.includeSigningCertificate ?? true,
signatureTypes: extractTeamSignatureSettings(settings),
}, },
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema), resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
}); });
@ -91,7 +84,7 @@ export const TeamDocumentPreferencesForm = ({
documentLanguage, documentLanguage,
includeSenderDetails, includeSenderDetails,
includeSigningCertificate, includeSigningCertificate,
signatureTypes, typedSignatureEnabled,
} = data; } = data;
await updateTeamDocumentPreferences({ await updateTeamDocumentPreferences({
@ -100,10 +93,8 @@ export const TeamDocumentPreferencesForm = ({
documentVisibility, documentVisibility,
documentLanguage, documentLanguage,
includeSenderDetails, includeSenderDetails,
typedSignatureEnabled,
includeSigningCertificate, includeSigningCertificate,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}, },
}); });
@ -199,44 +190,6 @@ export const TeamDocumentPreferencesForm = ({
)} )}
/> />
<FormField
control={form.control}
name="signatureTypes"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className="flex flex-row items-center">
<Trans>Default Signature Settings</Trans>
<DocumentSignatureSettingsTooltip />
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
enableSearch={false}
emptySelectionPlaceholder="Select signature types"
testId="signature-types-combobox"
/>
</FormControl>
{form.formState.errors.signatureTypes ? (
<FormMessage />
) : (
<FormDescription>
<Trans>
Controls which signatures are allowed to be used when signing a document.
</Trans>
</FormDescription>
)}
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="includeSenderDetails" name="includeSenderDetails"
@ -285,6 +238,36 @@ export const TeamDocumentPreferencesForm = ({
)} )}
/> />
<FormField
control={form.control}
name="typedSignatureEnabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enable Typed Signature</Trans>
</FormLabel>
<div>
<FormControl className="block">
<Switch
ref={field.ref}
name={field.name}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormDescription>
<Trans>
Controls whether the recipients can sign the documents using a typed signature.
Enable or disable the typed signature globally.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="includeSigningCertificate" name="includeSigningCertificate"
@ -318,7 +301,7 @@ export const TeamDocumentPreferencesForm = ({
<div className="flex flex-row justify-end space-x-4"> <div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}> <Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans> <Trans>Save</Trans>
</Button> </Button>
</div> </div>
</fieldset> </fieldset>

View File

@ -76,7 +76,7 @@ export const AppNavDesktop = ({
<Button <Button
variant="outline" variant="outline"
className="text-muted-foreground flex w-full max-w-96 items-center justify-between rounded-lg" className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
onClick={() => setIsCommandMenuOpen(true)} onClick={() => setIsCommandMenuOpen(true)}
> >
<div className="flex items-center"> <div className="flex items-center">

View File

@ -113,11 +113,7 @@ export const DirectTemplatePageView = ({
const redirectUrl = template.templateMeta?.redirectUrl; const redirectUrl = template.templateMeta?.redirectUrl;
if (redirectUrl) { await (redirectUrl ? navigate(redirectUrl) : navigate(`/sign/${token}/complete`));
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${token}/complete`);
}
} catch (err) { } catch (err) {
toast({ toast({
title: _(msg`Something went wrong`), title: _(msg`Something went wrong`),

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field, Recipient, Signature } from '@prisma/client'; import type { Field, Recipient, Signature } from '@prisma/client';
@ -24,6 +24,7 @@ import type {
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { import {
DocumentFlowFormContainerContent, DocumentFlowFormContainerContent,
DocumentFlowFormContainerFooter, DocumentFlowFormContainerFooter,
@ -34,7 +35,7 @@ import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/ty
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useStep } from '@documenso/ui/primitives/stepper'; import { useStep } from '@documenso/ui/primitives/stepper';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field'; import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
@ -72,7 +73,8 @@ export const DirectTemplateSigningForm = ({
template, template,
onSubmit, onSubmit,
}: DirectTemplateSigningFormProps) => { }: DirectTemplateSigningFormProps) => {
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); const { fullName, signature, signatureValid, setFullName, setSignature } =
useRequiredDocumentSigningContext();
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields); const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
@ -89,7 +91,7 @@ export const DirectTemplateSigningForm = ({
const tempField: DirectTemplateLocalField = { const tempField: DirectTemplateLocalField = {
...field, ...field,
customText: value.value ?? '', customText: value.value,
inserted: true, inserted: true,
signedValue: value, signedValue: value,
}; };
@ -100,8 +102,8 @@ export const DirectTemplateSigningForm = ({
created: new Date(), created: new Date(),
recipientId: 1, recipientId: 1,
fieldId: 1, fieldId: 1,
signatureImageAsBase64: value.value?.startsWith('data:') ? value.value : null, signatureImageAsBase64: value.value.startsWith('data:') ? value.value : null,
typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null, typedSignature: value.value.startsWith('data:') ? null : value.value,
} satisfies Signature; } satisfies Signature;
} }
@ -133,6 +135,8 @@ export const DirectTemplateSigningForm = ({
); );
}; };
const hasSignatureField = localFields.some((field) => field.type === FieldType.SIGNATURE);
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(localFields.filter((field) => !field.inserted)); return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
}, [localFields]); }, [localFields]);
@ -145,6 +149,10 @@ export const DirectTemplateSigningForm = ({
const handleSubmit = async () => { const handleSubmit = async () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
if (hasSignatureField && !signatureValid) {
return;
}
const isFieldsValid = validateFieldsInserted(localFields); const isFieldsValid = validateFieldsInserted(localFields);
if (!isFieldsValid) { if (!isFieldsValid) {
@ -162,55 +170,6 @@ export const DirectTemplateSigningForm = ({
// Do not reset to false since we do a redirect. // Do not reset to false since we do a redirect.
}; };
useEffect(() => {
const updatedFields = [...localFields];
localFields.forEach((field) => {
const index = updatedFields.findIndex((f) => f.id === field.id);
let value = '';
match(field.type)
.with(FieldType.TEXT, () => {
const meta = field.fieldMeta ? ZTextFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.text ?? '';
}
})
.with(FieldType.NUMBER, () => {
const meta = field.fieldMeta ? ZNumberFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.value ?? '';
}
})
.with(FieldType.DROPDOWN, () => {
const meta = field.fieldMeta ? ZDropdownFieldMeta.safeParse(field.fieldMeta) : null;
if (meta?.success) {
value = meta.data.defaultValue ?? '';
}
});
if (value) {
const signedValue = {
token: directRecipient.token,
fieldId: field.id,
value,
};
updatedFields[index] = {
...field,
customText: value,
inserted: true,
signedValue,
};
}
});
setLocalFields(updatedFields);
}, []);
return ( return (
<DocumentSigningRecipientProvider recipient={directRecipient}> <DocumentSigningRecipientProvider recipient={directRecipient}>
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} /> <DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
@ -232,8 +191,6 @@ export const DirectTemplateSigningForm = ({
onSignField={onSignField} onSignField={onSignField}
onUnsignField={onUnsignField} onUnsignField={onUnsignField}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
/> />
)) ))
.with(FieldType.INITIALS, () => ( .with(FieldType.INITIALS, () => (
@ -378,15 +335,19 @@ export const DirectTemplateSigningForm = ({
<Trans>Signature</Trans> <Trans>Signature</Trans>
</Label> </Label>
<SignaturePadDialog <Card className="mt-2" gradient degrees={-120}>
className="mt-2" <CardContent className="p-0">
disabled={isSubmitting} <SignaturePad
value={signature ?? ''} className="h-44 w-full"
onChange={(value) => setSignature(value)} disabled={isSubmitting}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled} defaultValue={signature ?? undefined}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled} onChange={(value) => {
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled} setSignature(value);
/> }}
allowTypedSignature={template.templateMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>

View File

@ -97,10 +97,6 @@ export const DocumentSigningCheckboxField = ({
const onSign = async (authOptions?: TRecipientActionAuth) => { const onSign = async (authOptions?: TRecipientActionAuth) => {
try { try {
if (!isLengthConditionMet) {
return;
}
const payload: TSignFieldWithTokenMutationSchema = { const payload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
@ -198,30 +194,18 @@ export const DocumentSigningCheckboxField = ({
setCheckedValues(updatedValues); setCheckedValues(updatedValues);
const removePayload: TRemovedSignedFieldWithTokenMutationSchema = { await removeSignedFieldWithToken({
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
}; });
if (onUnsignField) { if (updatedValues.length > 0) {
await onUnsignField(removePayload); await signFieldWithToken({
} else {
await removeSignedFieldWithToken(removePayload);
}
if (updatedValues.length > 0 && shouldAutoSignField) {
const signPayload: TSignFieldWithTokenMutationSchema = {
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value: toCheckboxValue(updatedValues), value: toCheckboxValue(updatedValues),
isBase64: true, isBase64: true,
}; });
if (onSignField) {
await onSignField(signPayload);
} else {
await signFieldWithToken(signPayload);
}
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -1,12 +1,8 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client'; import type { Field } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -17,15 +13,6 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
@ -34,23 +21,11 @@ export type DocumentSigningCompleteDialogProps = {
documentTitle: string; documentTitle: string;
fields: Field[]; fields: Field[];
fieldsValidated: () => void | Promise<void>; fieldsValidated: () => void | Promise<void>;
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>; onSignatureComplete: () => void | Promise<void>;
role: RecipientRole; role: RecipientRole;
disabled?: boolean; disabled?: boolean;
allowDictateNextSigner?: boolean;
defaultNextSigner?: {
name: string;
email: string;
};
}; };
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
export const DocumentSigningCompleteDialog = ({ export const DocumentSigningCompleteDialog = ({
isSubmitting, isSubmitting,
documentTitle, documentTitle,
@ -59,54 +34,19 @@ export const DocumentSigningCompleteDialog = ({
onSignatureComplete, onSignatureComplete,
role, role,
disabled = false, disabled = false,
allowDictateNextSigner = false,
defaultNextSigner,
}: DocumentSigningCompleteDialogProps) => { }: DocumentSigningCompleteDialogProps) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
},
});
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]); const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
const handleOpenChange = (open: boolean) => { const handleOpenChange = (open: boolean) => {
if (form.formState.isSubmitting || !isComplete) { if (isSubmitting || !isComplete) {
return; return;
} }
if (open) {
form.reset({
name: defaultNextSigner?.name ?? '',
email: defaultNextSigner?.email ?? '',
});
}
setIsEditingNextSigner(false);
setShowDialog(open); setShowDialog(open);
}; };
const onFormSubmit = async (data: TNextSignerFormSchema) => {
console.log('data', data);
console.log('form.formState.errors', form.formState.errors);
try {
if (allowDictateNextSigner && data.name && data.email) {
await onSignatureComplete({ name: data.name, email: data.email });
} else {
await onSignatureComplete();
}
} catch (error) {
console.error('Error completing signature:', error);
}
};
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
return ( return (
<Dialog open={showDialog} onOpenChange={handleOpenChange}> <Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
@ -118,196 +58,92 @@ export const DocumentSigningCompleteDialog = ({
loading={isSubmitting} loading={isSubmitting}
disabled={disabled} disabled={disabled}
> >
{match({ isComplete, role }) {isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
<Trans>Mark as viewed</Trans>
))
.with({ isComplete: true }, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<Form {...form}> <DialogTitle>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <div className="text-foreground text-xl font-semibold">
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0"> {role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
<DialogTitle> {role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
<div className="text-foreground text-xl font-semibold"> {role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
{match(role) </div>
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>) </DialogTitle>
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
.exhaustive()}
</div>
</DialogTitle>
<div className="text-muted-foreground max-w-[50ch]"> <div className="text-muted-foreground max-w-[50ch]">
{match(role) {role === RecipientRole.VIEWER && (
.with(RecipientRole.VIEWER, () => ( <span>
<span> <Trans>
<Trans> <span className="inline-flex flex-wrap">
<span className="inline-flex flex-wrap"> You are about to complete viewing "
You are about to complete viewing " <span className="inline-block max-w-[11rem] truncate align-baseline">
<span className="inline-block max-w-[11rem] truncate align-baseline"> {documentTitle}
{documentTitle} </span>
</span> ".
". </span>
</span> <br /> Are you sure?
<br /> Are you sure? </Trans>
</Trans> </span>
</span> )}
)) {role === RecipientRole.SIGNER && (
.with(RecipientRole.SIGNER, () => ( <span>
<span> <Trans>
<Trans> <span className="inline-flex flex-wrap">
<span className="inline-flex flex-wrap"> You are about to complete signing "
You are about to complete signing " <span className="inline-block max-w-[11rem] truncate align-baseline">
<span className="inline-block max-w-[11rem] truncate align-baseline"> {documentTitle}
{documentTitle} </span>
</span> ".
". </span>
</span> <br /> Are you sure?
<br /> Are you sure? </Trans>
</Trans> </span>
</span> )}
)) {role === RecipientRole.APPROVER && (
.with(RecipientRole.APPROVER, () => ( <span>
<span> <Trans>
<Trans> <span className="inline-flex flex-wrap">
<span className="inline-flex flex-wrap"> You are about to complete approving{' '}
You are about to complete approving{' '} <span className="inline-block max-w-[11rem] truncate align-baseline">
<span className="inline-block max-w-[11rem] truncate align-baseline"> "{documentTitle}"
"{documentTitle}" </span>
</span> .
. </span>
</span> <br /> Are you sure?
<br /> Are you sure? </Trans>
</Trans> </span>
</span> )}
)) </div>
.otherwise(() => (
<span>
<Trans>
<span className="inline-flex flex-wrap">
You are about to complete viewing "
<span className="inline-block max-w-[11rem] truncate align-baseline">
{documentTitle}
</span>
".
</span>
<br /> Are you sure?
</Trans>
</span>
))}
</div>
{allowDictateNextSigner && ( <DocumentSigningDisclosure className="mt-4" />
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</p>
<Button <DialogFooter>
type="button" <div className="flex w-full flex-1 flex-nowrap gap-4">
className="mt-2" <Button
variant="outline" type="button"
size="sm" className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
onClick={() => setIsEditingNextSigner((prev) => !prev)} variant="secondary"
> onClick={() => {
<Trans>Update Recipient</Trans> setShowDialog(false);
</Button> }}
</div> >
)} <Trans>Cancel</Trans>
</Button>
{isEditingNextSigner && ( <Button
<div className="flex flex-col gap-4 md:flex-row"> type="button"
<FormField className="flex-1"
control={form.control} disabled={!isComplete}
name="name" loading={isSubmitting}
render={({ field }) => ( onClick={onSignatureComplete}
<FormItem className="flex-1"> >
<FormLabel> {role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
<Trans>Name</Trans> {role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
</FormLabel> {role === RecipientRole.APPROVER && <Trans>Approve</Trans>}
<FormControl> </Button>
<Input </div>
{...field} </DialogFooter>
className="mt-2"
placeholder="Enter the next signer's name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
className="mt-2"
placeholder="Enter the next signer's email"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
)}
<DocumentSigningDisclosure className="mt-4" />
<DialogFooter className="mt-4">
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => setShowDialog(false)}
disabled={form.formState.isSubmitting}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
className="flex-1"
disabled={!isComplete || !isNextSignerValid}
loading={form.formState.isSubmitting}
>
{match(role)
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
.exhaustive()}
</Button>
</div>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -181,23 +181,6 @@ export const DocumentSigningFieldContainer = ({
</button> </button>
)} )}
{(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) &&
field.fieldMeta?.label && (
<div
className={cn(
'absolute -top-16 left-0 right-0 rounded-md p-2 text-center text-xs text-gray-700',
{
'bg-foreground/5 border-border border': !field.inserted,
},
{
'bg-documenso-200 border-primary border': field.inserted,
},
)}
>
{field.fieldMeta.label}
</div>
)}
{children} {children}
</FieldRootContainer> </FieldRootContainer>
</div> </div>

View File

@ -18,16 +18,14 @@ import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { import { AssistantConfirmationDialog } from '../../dialogs/assistant-confirmation-dialog';
AssistantConfirmationDialog,
type NextSigner,
} from '../../dialogs/assistant-confirmation-dialog';
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
import { useRequiredDocumentSigningContext } from './document-signing-provider'; import { useRequiredDocumentSigningContext } from './document-signing-provider';
@ -61,17 +59,15 @@ export const DocumentSigningForm = ({
const assistantSignersId = useId(); const assistantSignersId = useId();
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext(); const { fullName, signature, setFullName, setSignature, signatureValid, setSignatureValid } =
useRequiredDocumentSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false); const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false); const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
const { const { mutateAsync: completeDocumentWithToken } =
mutateAsync: completeDocumentWithToken, trpc.recipient.completeDocumentWithToken.useMutation();
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({ const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
defaultValues: { defaultValues: {
@ -79,8 +75,10 @@ export const DocumentSigningForm = ({
}, },
}); });
const { handleSubmit, formState } = useForm();
// Keep the loading state going if successful since the redirect may take some time. // Keep the loading state going if successful since the redirect may take some time.
const isSubmitting = isPending || isSuccess; const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
const fieldsRequiringValidation = useMemo( const fieldsRequiringValidation = useMemo(
() => fields.filter(isFieldUnsignedAndRequired), () => fields.filter(isFieldUnsignedAndRequired),
@ -102,6 +100,22 @@ export const DocumentSigningForm = ({
validateFieldsInserted(fieldsRequiringValidation); validateFieldsInserted(fieldsRequiringValidation);
}; };
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fieldsRequiringValidation);
if (hasSignatureField && !signatureValid) {
return;
}
if (!isFieldsValid) {
return;
}
await completeDocument();
};
const onAssistantFormSubmit = () => { const onAssistantFormSubmit = () => {
if (uninsertedRecipientFields.length > 0) { if (uninsertedRecipientFields.length > 0) {
return; return;
@ -110,11 +124,11 @@ export const DocumentSigningForm = ({
setIsConfirmationDialogOpen(true); setIsConfirmationDialogOpen(true);
}; };
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => { const handleAssistantConfirmDialogSubmit = async () => {
setIsAssistantSubmitting(true); setIsAssistantSubmitting(true);
try { try {
await completeDocument(undefined, nextSigner); await completeDocument();
} catch (err) { } catch (err) {
toast({ toast({
title: 'Error', title: 'Error',
@ -127,18 +141,12 @@ export const DocumentSigningForm = ({
} }
}; };
const completeDocument = async ( const completeDocument = async (authOptions?: TRecipientActionAuth) => {
authOptions?: TRecipientActionAuth, await completeDocumentWithToken({
nextSigner?: { email: string; name: string },
) => {
const payload = {
token: recipient.token, token: recipient.token,
documentId: document.id, documentId: document.id,
authOptions, authOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}), });
};
await completeDocumentWithToken(payload);
analytics.capture('App: Recipient has completed signing', { analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id, signerId: recipient.id,
@ -153,29 +161,6 @@ export const DocumentSigningForm = ({
} }
}; };
const nextRecipient = useMemo(() => {
if (
!document.documentMeta?.signingOrder ||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
) {
return undefined;
}
const sortedRecipients = allRecipients.sort((a, b) => {
// Sort by signingOrder first (nulls last), then by id
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
if (a.signingOrder === null) return 1;
if (b.signingOrder === null) return -1;
if (a.signingOrder === b.signingOrder) return a.id - b.id;
return a.signingOrder - b.signingOrder;
});
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
? sortedRecipients[currentIndex + 1]
: undefined;
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
return ( return (
<div <div
className={cn( className={cn(
@ -225,19 +210,12 @@ export const DocumentSigningForm = ({
<DocumentSigningCompleteDialog <DocumentSigningCompleteDialog
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title} documentTitle={document.title}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role} role={recipient.role}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} disabled={!isRecipientsTurn}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/> />
</div> </div>
</div> </div>
@ -316,8 +294,9 @@ export const DocumentSigningForm = ({
className="w-full" className="w-full"
size="lg" size="lg"
loading={isAssistantSubmitting} loading={isAssistantSubmitting}
disabled={isAssistantSubmitting || uninsertedRecipientFields.length > 0}
> >
<Trans>Continue</Trans> {isAssistantSubmitting ? <Trans>Submitting...</Trans> : <Trans>Continue</Trans>}
</Button> </Button>
</div> </div>
@ -327,26 +306,14 @@ export const DocumentSigningForm = ({
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)} onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
onConfirm={handleAssistantConfirmDialogSubmit} onConfirm={handleAssistantConfirmDialogSubmit}
isSubmitting={isAssistantSubmitting} isSubmitting={isAssistantSubmitting}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/> />
</form> </form>
</> </>
) : ( ) : (
<> <>
<div> <form onSubmit={handleSubmit(onFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? ( <Trans>Please review the document before signing.</Trans>
<Trans>Please review the document before approving.</Trans>
) : (
<Trans>Please review the document before signing.</Trans>
)}
</p> </p>
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4" />
@ -370,59 +337,64 @@ export const DocumentSigningForm = ({
/> />
</div> </div>
{hasSignatureField && ( <div>
<div> <Label htmlFor="Signature">
<Label htmlFor="Signature"> <Trans>Signature</Trans>
<Trans>Signature</Trans> </Label>
</Label>
<SignaturePadDialog <Card className="mt-2" gradient degrees={-120}>
className="mt-2" <CardContent className="p-0">
disabled={isSubmitting} <SignaturePad
value={signature ?? ''} className="h-44 w-full"
onChange={(v) => setSignature(v ?? '')} disabled={isSubmitting}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled} defaultValue={signature ?? undefined}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled} onValidityChange={(isValid) => {
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled} setSignatureValid(isValid);
/> }}
</div> onChange={(value) => {
)} if (signatureValid) {
setSignature(value);
}
}}
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
/>
</CardContent>
</Card>
{hasSignatureField && !signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>
Signature is too small. Please provide a more complete signature.
</Trans>
</div>
)}
</div>
</div>
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
disabled={!isRecipientsTurn}
/>
</div> </div>
</fieldset> </fieldset>
</form>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
</div>
</> </>
)} )}
</div> </div>

View File

@ -40,9 +40,9 @@ import { DocumentReadOnlyFields } from '~/components/general/document/document-r
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
export type DocumentSigningPageViewProps = { export type SigningPageViewProps = {
recipient: RecipientWithFields;
document: DocumentAndSender; document: DocumentAndSender;
recipient: RecipientWithFields;
fields: Field[]; fields: Field[];
completedFields: CompletedField[]; completedFields: CompletedField[];
isRecipientsTurn: boolean; isRecipientsTurn: boolean;
@ -50,13 +50,13 @@ export type DocumentSigningPageViewProps = {
}; };
export const DocumentSigningPageView = ({ export const DocumentSigningPageView = ({
recipient,
document, document,
recipient,
fields, fields,
completedFields, completedFields,
isRecipientsTurn, isRecipientsTurn,
allRecipients = [], allRecipients = [],
}: DocumentSigningPageViewProps) => { }: SigningPageViewProps) => {
const { documentData, documentMeta } = document; const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id); const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
@ -157,7 +157,7 @@ export const DocumentSigningPageView = ({
</div> </div>
</div> </div>
<DocumentReadOnlyFields documentMeta={documentMeta || undefined} fields={completedFields} /> <DocumentReadOnlyFields fields={completedFields} />
{recipient.role !== RecipientRole.ASSISTANT && ( {recipient.role !== RecipientRole.ASSISTANT && (
<DocumentSigningAutoSign recipient={recipient} fields={fields} /> <DocumentSigningAutoSign recipient={recipient} fields={fields} />
@ -177,8 +177,6 @@ export const DocumentSigningPageView = ({
key={field.id} key={field.id}
field={field} field={field}
typedSignatureEnabled={documentMeta?.typedSignatureEnabled} typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
/> />
)) ))
.with(FieldType.INITIALS, () => ( .with(FieldType.INITIALS, () => (

View File

@ -1,6 +1,4 @@
import { createContext, useContext, useState } from 'react'; import { createContext, useContext, useEffect, useState } from 'react';
import { isBase64Image } from '@documenso/lib/constants/signatures';
export type DocumentSigningContextValue = { export type DocumentSigningContextValue = {
fullName: string; fullName: string;
@ -9,6 +7,8 @@ export type DocumentSigningContextValue = {
setEmail: (_value: string) => void; setEmail: (_value: string) => void;
signature: string | null; signature: string | null;
setSignature: (_value: string | null) => void; setSignature: (_value: string | null) => void;
signatureValid: boolean;
setSignatureValid: (_valid: boolean) => void;
}; };
const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null); const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null);
@ -31,9 +31,6 @@ export interface DocumentSigningProviderProps {
fullName?: string | null; fullName?: string | null;
email?: string | null; email?: string | null;
signature?: string | null; signature?: string | null;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
children: React.ReactNode; children: React.ReactNode;
} }
@ -41,31 +38,18 @@ export const DocumentSigningProvider = ({
fullName: initialFullName, fullName: initialFullName,
email: initialEmail, email: initialEmail,
signature: initialSignature, signature: initialSignature,
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
children, children,
}: DocumentSigningProviderProps) => { }: DocumentSigningProviderProps) => {
const [fullName, setFullName] = useState(initialFullName || ''); const [fullName, setFullName] = useState(initialFullName || '');
const [email, setEmail] = useState(initialEmail || ''); const [email, setEmail] = useState(initialEmail || '');
const [signature, setSignature] = useState(initialSignature || null);
const [signatureValid, setSignatureValid] = useState(true);
// Ensure the user signature doesn't show up if it's not allowed. useEffect(() => {
const [signature, setSignature] = useState( if (initialSignature) {
(() => { setSignature(initialSignature);
const sig = initialSignature || ''; }
const isBase64 = isBase64Image(sig); }, [initialSignature]);
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
return sig;
}
if (!isBase64 && typedSignatureEnabled) {
return sig;
}
return null;
})(),
);
return ( return (
<DocumentSigningContext.Provider <DocumentSigningContext.Provider
@ -76,6 +60,8 @@ export const DocumentSigningProvider = ({
setEmail, setEmail,
signature, signature,
setSignature, setSignature,
signatureValid,
setSignatureValid,
}} }}
> >
{children} {children}

View File

@ -31,7 +31,10 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZRejectDocumentFormSchema = z.object({ const ZRejectDocumentFormSchema = z.object({
reason: z.string().max(500, msg`Reason must be less than 500 characters`), reason: z
.string()
.min(5, msg`Please provide a reason`)
.max(500, msg`Reason must be less than 500 characters`),
}); });
type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>; type TRejectDocumentFormSchema = z.infer<typeof ZRejectDocumentFormSchema>;

View File

@ -17,6 +17,7 @@ import type {
} from '@documenso/trpc/server/field-router/schema'; } from '@documenso/trpc/server/field-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -28,14 +29,11 @@ import { useRequiredDocumentSigningContext } from './document-signing-provider';
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider'; import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text'; type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
export type DocumentSigningSignatureFieldProps = { export type DocumentSigningSignatureFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
typedSignatureEnabled?: boolean; typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
}; };
export const DocumentSigningSignatureField = ({ export const DocumentSigningSignatureField = ({
@ -43,8 +41,6 @@ export const DocumentSigningSignatureField = ({
onSignField, onSignField,
onUnsignField, onUnsignField,
typedSignatureEnabled, typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
}: DocumentSigningSignatureFieldProps) => { }: DocumentSigningSignatureFieldProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@ -56,8 +52,12 @@ export const DocumentSigningSignatureField = ({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState(2); const [fontSize, setFontSize] = useState(2);
const { signature: providedSignature, setSignature: setProvidedSignature } = const {
useRequiredDocumentSigningContext(); signature: providedSignature,
setSignature: setProvidedSignature,
signatureValid,
setSignatureValid,
} = useRequiredDocumentSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext(); const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@ -89,7 +89,7 @@ export const DocumentSigningSignatureField = ({
}, [field.inserted, signature?.signatureImageAsBase64]); }, [field.inserted, signature?.signatureImageAsBase64]);
const onPreSign = () => { const onPreSign = () => {
if (!providedSignature) { if (!providedSignature || !signatureValid) {
setShowSignatureModal(true); setShowSignatureModal(true);
return false; return false;
} }
@ -102,7 +102,6 @@ export const DocumentSigningSignatureField = ({
const onDialogSignClick = () => { const onDialogSignClick = () => {
setShowSignatureModal(false); setShowSignatureModal(false);
setProvidedSignature(localSignature); setProvidedSignature(localSignature);
if (!localSignature) { if (!localSignature) {
return; return;
} }
@ -117,14 +116,14 @@ export const DocumentSigningSignatureField = ({
try { try {
const value = signature || providedSignature; const value = signature || providedSignature;
if (!value) { if (!value || (signature && !signatureValid)) {
setShowSignatureModal(true); setShowSignatureModal(true);
return; return;
} }
const isTypedSignature = !value.startsWith('data:image'); const isTypedSignature = !value.startsWith('data:image');
if (isTypedSignature && typedSignatureEnabled === false) { if (isTypedSignature && !typedSignatureEnabled) {
toast({ toast({
title: _(msg`Error`), title: _(msg`Error`),
description: _(msg`Typed signatures are not allowed. Please draw your signature.`), description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
@ -276,14 +275,29 @@ export const DocumentSigningSignatureField = ({
</Trans> </Trans>
</DialogTitle> </DialogTitle>
<SignaturePad <div className="">
className="mt-2" <Label htmlFor="signature">
value={localSignature ?? ''} <Trans>Signature</Trans>
onChange={({ value }) => setLocalSignature(value)} </Label>
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled} <div className="border-border mt-2 rounded-md border">
drawSignatureEnabled={drawSignatureEnabled} <SignaturePad
/> id="signature"
className="h-44 w-full"
onChange={(value) => setLocalSignature(value)}
allowTypedSignature={typedSignatureEnabled}
onValidityChange={(isValid) => {
setSignatureValid(isValid);
}}
/>
</div>
{!signatureValid && (
<div className="text-destructive mt-2 text-sm">
<Trans>Signature is too small. Please provide a more complete signature.</Trans>
</div>
)}
</div>
<DocumentSigningDisclosure /> <DocumentSigningDisclosure />
@ -303,7 +317,7 @@ export const DocumentSigningSignatureField = ({
<Button <Button
type="button" type="button"
className="flex-1" className="flex-1"
disabled={!localSignature} disabled={!localSignature || !signatureValid}
onClick={() => onDialogSignClick()} onClick={() => onDialogSignClick()}
> >
<Trans>Sign</Trans> <Trans>Sign</Trans>

View File

@ -1,10 +1,9 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { DocumentStatus } from '@prisma/client'; import { DocumentStatus } from '@prisma/client';
import { DownloadIcon } from 'lucide-react'; import { DownloadIcon } from 'lucide-react';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -77,7 +76,7 @@ export const DocumentCertificateDownloadButton = ({
className={cn('w-full sm:w-auto', className)} className={cn('w-full sm:w-auto', className)}
loading={isPending} loading={isPending}
variant="outline" variant="outline"
disabled={!isDocumentCompleted(documentStatus)} disabled={documentStatus !== DocumentStatus.COMPLETED}
onClick={() => void onDownloadCertificatesClick()} onClick={() => void onDownloadCertificatesClick()}
> >
{!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />} {!isPending && <DownloadIcon className="mr-1.5 h-4 w-4" />}

View File

@ -5,7 +5,6 @@ import { useLingui } from '@lingui/react';
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client'; import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
import { useNavigate, useSearchParams } from 'react-router'; import { useNavigate, useSearchParams } from 'react-router';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
@ -72,7 +71,7 @@ export const DocumentEditForm = ({
const { recipients, fields } = document; const { recipients, fields } = document;
const { mutateAsync: updateDocument } = trpc.document.updateDocument.useMutation({ const { mutateAsync: updateDocument } = trpc.document.setSettingsForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => { onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData( utils.document.getDocumentWithDetailsById.setData(
@ -175,7 +174,7 @@ export const DocumentEditForm = ({
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => { const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try { try {
const { timezone, dateFormat, redirectUrl, language, signatureTypes } = data.meta; const { timezone, dateFormat, redirectUrl, language } = data.meta;
await updateDocument({ await updateDocument({
documentId: document.id, documentId: document.id,
@ -191,9 +190,6 @@ export const DocumentEditForm = ({
dateFormat, dateFormat,
redirectUrl, redirectUrl,
language: isValidLanguageCode(language) ? language : undefined, language: isValidLanguageCode(language) ? language : undefined,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}, },
}); });
@ -217,13 +213,6 @@ export const DocumentEditForm = ({
signingOrder: data.signingOrder, signingOrder: data.signingOrder,
}), }),
updateDocument({
documentId: document.id,
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
},
}),
setRecipients({ setRecipients({
documentId: document.id, documentId: document.id,
recipients: data.signers.map((signer) => ({ recipients: data.signers.map((signer) => ({
@ -253,6 +242,14 @@ export const DocumentEditForm = ({
fields: data.fields, fields: data.fields,
}); });
await updateDocument({
documentId: document.id,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage // Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
@ -368,7 +365,6 @@ export const DocumentEditForm = ({
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
signingOrder={document.documentMeta?.signingOrder} signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
fields={fields} fields={fields}
isDocumentEnterprise={isDocumentEnterprise} isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}
@ -382,6 +378,7 @@ export const DocumentEditForm = ({
fields={fields} fields={fields}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
teamId={team?.id} teamId={team?.id}
/> />

View File

@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -33,7 +32,7 @@ export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps
const isRecipient = !!recipient; const isRecipient = !!recipient;
const isPending = document.status === DocumentStatus.PENDING; const isPending = document.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(document); const isComplete = document.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role; const role = recipient?.role;

View File

@ -20,7 +20,6 @@ import { useNavigate } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -64,7 +63,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
const isDraft = document.status === DocumentStatus.DRAFT; const isDraft = document.status === DocumentStatus.DRAFT;
const isPending = document.status === DocumentStatus.PENDING; const isPending = document.status === DocumentStatus.PENDING;
const isDeleted = document.deletedAt !== null; const isDeleted = document.deletedAt !== null;
const isComplete = isDocumentCompleted(document); const isComplete = document.status === DocumentStatus.COMPLETED;
const isCurrentTeamDocument = team && document.team?.url === team.url; const isCurrentTeamDocument = team && document.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);

View File

@ -17,7 +17,6 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature'; import { SignatureIcon } from '@documenso/ui/icons/signature';
@ -49,7 +48,7 @@ export const DocumentPageViewRecipients = ({
<Trans>Recipients</Trans> <Trans>Recipients</Trans>
</h1> </h1>
{!isDocumentCompleted(document.status) && ( {document.status !== DocumentStatus.COMPLETED && (
<Link <Link
to={`${documentRootPath}/${document.id}/edit?step=signers`} to={`${documentRootPath}/${document.id}/edit?step=signers`}
title={_(msg`Modify recipients`)} title={_(msg`Modify recipients`)}

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, TemplateMeta } from '@prisma/client'; import type { DocumentMeta } from '@prisma/client';
import { FieldType, SigningStatus } from '@prisma/client'; import { FieldType, SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react'; import { Clock, EyeOffIcon } from 'lucide-react';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
@ -27,7 +27,7 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
export type DocumentReadOnlyFieldsProps = { export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[]; fields: DocumentField[];
documentMeta?: DocumentMeta | TemplateMeta; documentMeta?: DocumentMeta;
showFieldStatus?: boolean; showFieldStatus?: boolean;
}; };

View File

@ -3,7 +3,7 @@ import type { HTMLAttributes } from 'react';
import type { MessageDescriptor } from '@lingui/core'; import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { CheckCircle2, Clock, File, XCircle } from 'lucide-react'; import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@ -36,12 +36,6 @@ export const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus>
icon: File, icon: File,
color: 'text-yellow-500 dark:text-yellow-200', color: 'text-yellow-500 dark:text-yellow-200',
}, },
REJECTED: {
label: msg`Rejected`,
labelExtended: msg`Document rejected`,
icon: XCircle,
color: 'text-red-500 dark:text-red-300',
},
INBOX: { INBOX: {
label: msg`Inbox`, label: msg`Inbox`,
labelExtended: msg`Document inbox`, labelExtended: msg`Document inbox`,

View File

@ -62,7 +62,7 @@ export const GenericErrorLayout = ({
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const { subHeading, heading, message } = const { subHeading, heading, message } =
errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500]; errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500];
return ( return (
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center"> <div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">

View File

@ -2,9 +2,6 @@ import { useCallback, useEffect } from 'react';
import { useRevalidator } from 'react-router'; import { useRevalidator } from 'react-router';
/**
* Not really used anymore, this causes random 500s when the user refreshes while this occurs.
*/
export const RefreshOnFocus = () => { export const RefreshOnFocus = () => {
const { revalidate, state } = useRevalidator(); const { revalidate, state } = useRevalidator();

View File

@ -4,7 +4,6 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { DocumentSignatureType } from '@documenso/lib/constants/document';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n'; import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import { import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
@ -125,8 +124,6 @@ export const TemplateEditForm = ({
}); });
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => { const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
const { signatureTypes } = data.meta;
try { try {
await updateTemplateSettings({ await updateTemplateSettings({
templateId: template.id, templateId: template.id,
@ -139,9 +136,6 @@ export const TemplateEditForm = ({
}, },
meta: { meta: {
...data.meta, ...data.meta,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined, language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
}, },
}); });
@ -167,7 +161,6 @@ export const TemplateEditForm = ({
templateId: template.id, templateId: template.id,
meta: { meta: {
signingOrder: data.signingOrder, signingOrder: data.signingOrder,
allowDictateNextSigner: data.allowDictateNextSigner,
}, },
}), }),
@ -194,6 +187,13 @@ export const TemplateEditForm = ({
fields: data.fields, fields: data.fields,
}); });
await updateTemplateSettings({
templateId: template.id,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage // Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i); const key = localStorage.key(i);
@ -271,7 +271,6 @@ export const TemplateEditForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
signingOrder={template.templateMeta?.signingOrder} signingOrder={template.templateMeta?.signingOrder}
allowDictateNextSigner={template.templateMeta?.allowDictateNextSigner}
templateDirectLink={template.directLink} templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit} onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise} isEnterprise={isEnterprise}
@ -285,6 +284,7 @@ export const TemplateEditForm = ({
fields={fields} fields={fields}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
teamId={team?.id} teamId={team?.id}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/> />
</Stepper> </Stepper>
</DocumentFlowFormContainer> </DocumentFlowFormContainer>

View File

@ -9,7 +9,6 @@ import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -38,7 +37,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isRecipient = !!recipient; const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status); const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role; const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;

View File

@ -22,7 +22,6 @@ import { Link } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -67,7 +66,7 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
// const isRecipient = !!recipient; // const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status); const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isCurrentTeamDocument = team && row.team?.url === team.url; const isCurrentTeamDocument = team && row.team?.url === team.url;
const canManageDocument = Boolean(isOwner || isCurrentTeamDocument); const canManageDocument = Boolean(isOwner || isCurrentTeamDocument);

View File

@ -9,8 +9,8 @@ import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema'; import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
@ -77,7 +77,7 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
{ {
header: _(msg`Actions`), header: _(msg`Actions`),
cell: ({ row }) => cell: ({ row }) =>
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && ( (!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} /> <DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} /> <DocumentsTableActionDropdown row={row.original} />

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import Plausible from 'plausible-tracker'; import Plausible from 'plausible-tracker';
import posthog from 'posthog-js';
import { import {
Links, Links,
Meta, Meta,
@ -27,6 +28,7 @@ import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import type { Route } from './+types/root'; import type { Route } from './+types/root';
import stylesheet from './app.css?url'; import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout'; import { GenericErrorLayout } from './components/general/generic-error-layout';
import { RefreshOnFocus } from './components/general/refresh-on-focus';
import { langCookie } from './storage/lang-cookie.server'; import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server'; import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta'; import { appMetaTags } from './utils/meta';
@ -158,6 +160,8 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
<RefreshOnFocus />
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`, __html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
@ -177,6 +181,7 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (errorCode !== 404) { if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error); console.error('[RootErrorBoundary]', error);
posthog.captureException(error);
} }
return <GenericErrorLayout errorCode={errorCode} />; return <GenericErrorLayout errorCode={errorCode} />;

View File

@ -103,9 +103,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
variant="outline" variant="outline"
loading={isResealDocumentLoading} loading={isResealDocumentLoading}
disabled={document.recipients.some( disabled={document.recipients.some(
(recipient) => (recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED,
)} )}
onClick={() => resealDocument({ id: document.id })} onClick={() => resealDocument({ id: document.id })}
> >

View File

@ -23,12 +23,12 @@ import {
getUsersWithSubscriptionsCount, getUsersWithSubscriptionsCount,
} from '@documenso/lib/server-only/admin/get-users-stats'; } from '@documenso/lib/server-only/admin/get-users-stats';
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion'; import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
import { env } from '@documenso/lib/utils/env';
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart'; import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents'; import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
import { CardMetric } from '~/components/general/metric-card'; import { CardMetric } from '~/components/general/metric-card';
import { version } from '../../../../package.json';
import type { Route } from './+types/stats'; import type { Route } from './+types/stats';
export async function loader() { export async function loader() {
@ -89,7 +89,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
value={usersWithSubscriptionsCount} value={usersWithSubscriptionsCount}
/> />
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${version}`} /> <CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${env('APP_VERSION')}`} />
</div> </div>
<div className="mt-16 gap-8"> <div className="mt-16 gap-8">

View File

@ -220,9 +220,6 @@ export default function DocumentPage() {
.with(DocumentStatus.COMPLETED, () => ( .with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans> <Trans>This document has been signed by all recipients</Trans>
)) ))
.with(DocumentStatus.REJECTED, () => (
<Trans>This document has been rejected by a recipient</Trans>
))
.with(DocumentStatus.DRAFT, () => ( .with(DocumentStatus.DRAFT, () => (
<Trans>This document is currently a draft and has not been sent</Trans> <Trans>This document is currently a draft and has not been sent</Trans>
)) ))

View File

@ -1,5 +1,5 @@
import { Plural, Trans } from '@lingui/react/macro'; import { Plural, Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client'; import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react'; import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router'; import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -9,7 +9,6 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team'; import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form'; import { DocumentEditForm } from '~/components/general/document/document-edit-form';
@ -72,7 +71,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath); throw redirect(documentRootPath);
} }
if (isDocumentCompleted(document.status)) { if (document.status === InternalDocumentStatus.COMPLETED) {
throw redirect(`${documentRootPath}/${documentId}`); throw redirect(`${documentRootPath}/${documentId}`);
} }

View File

@ -129,7 +129,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<Trans>Document</Trans> <Trans>Document</Trans>
</Link> </Link>
<div className="flex flex-col"> <div className="flex flex-col justify-between truncate sm:flex-row">
<div> <div>
<h1 <h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl" className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
@ -137,8 +137,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
> >
{document.title} {document.title}
</h1> </h1>
</div>
<div className="mt-1 flex flex-col justify-between sm:flex-row">
<div className="mt-2.5 flex items-center gap-x-6"> <div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent <DocumentStatusComponent
inheritColor inheritColor
@ -146,15 +145,16 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
className="text-muted-foreground" className="text-muted-foreground"
/> />
</div> </div>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end"> </div>
<DocumentCertificateDownloadButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton documentId={document.id} /> <div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
</div> <DocumentCertificateDownloadButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton documentId={document.id} />
</div> </div>
</div> </div>
@ -163,7 +163,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
{documentInformation.map((info, i) => ( {documentInformation.map((info, i) => (
<div className="text-foreground text-sm" key={i}> <div className="text-foreground text-sm" key={i}>
<h3 className="font-semibold">{_(info.description)}</h3> <h3 className="font-semibold">{_(info.description)}</h3>
<p className="text-muted-foreground truncate">{info.value}</p> <p className="text-muted-foreground">{info.value}</p>
</div> </div>
))} ))}

View File

@ -50,7 +50,6 @@ export default function DocumentsPage() {
[ExtendedDocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0, [ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0, [ExtendedDocumentStatus.ALL]: 0,
}); });

View File

@ -1,12 +1,11 @@
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { FieldType, SigningStatus } from '@prisma/client'; import { FieldType } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { redirect } from 'react-router'; import { redirect } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js'; import { UAParser } from 'ua-parser-js';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n'; import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
import { import {
RECIPIENT_ROLES_DESCRIPTION, RECIPIENT_ROLES_DESCRIPTION,
@ -60,8 +59,6 @@ export async function loader({ request }: Route.LoaderArgs) {
throw redirect('/'); throw redirect('/');
} }
const isPlatformDocument = await isDocumentPlatform(document);
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language); const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
const auditLogs = await getDocumentCertificateAuditLogs({ const auditLogs = await getDocumentCertificateAuditLogs({
@ -73,7 +70,6 @@ export async function loader({ request }: Route.LoaderArgs) {
return { return {
document, document,
documentLanguage, documentLanguage,
isPlatformDocument,
auditLogs, auditLogs,
messages, messages,
}; };
@ -89,7 +85,7 @@ export async function loader({ request }: Route.LoaderArgs) {
* Update: Maybe <Trans> tags work now after RR7 migration. * Update: Maybe <Trans> tags work now after RR7 migration.
*/ */
export default function SigningCertificate({ loaderData }: Route.ComponentProps) { export default function SigningCertificate({ loaderData }: Route.ComponentProps) {
const { document, documentLanguage, isPlatformDocument, auditLogs, messages } = loaderData; const { document, documentLanguage, auditLogs, messages } = loaderData;
const { i18n, _ } = useLingui(); const { i18n, _ } = useLingui();
@ -163,13 +159,6 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED && log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED &&
log.data.recipientId === recipientId, log.data.recipientId === recipientId,
), ),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED
].filter(
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED &&
log.data.recipientId === recipientId,
),
}; };
}; };
@ -293,42 +282,25 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</span> </span>
</p> </p>
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? ( <p className="text-muted-foreground text-sm print:text-xs">
<p className="text-muted-foreground text-sm print:text-xs"> <span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '} <span className="inline-block">
<span className="inline-block"> {logs.DOCUMENT_RECIPIENT_COMPLETED[0]
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt) .setLocale(APP_I18N_OPTIONS.defaultLocale)
.setLocale(APP_I18N_OPTIONS.defaultLocale) .toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)') : _(msg`Unknown`)}
: _(msg`Unknown`)} </span>
</span> </p>
</p>
) : (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(
logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt,
)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
)}
<p className="text-muted-foreground text-sm print:text-xs"> <p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Reason`)}:</span>{' '} <span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="inline-block"> <span className="inline-block">
{recipient.signingStatus === SigningStatus.REJECTED {_(
? recipient.rejectionReason isOwner(recipient.email)
: _( ? FRIENDLY_SIGNING_REASONS['__OWNER__']
isOwner(recipient.email) : FRIENDLY_SIGNING_REASONS[recipient.role],
? FRIENDLY_SIGNING_REASONS['__OWNER__'] )}
: FRIENDLY_SIGNING_REASONS[recipient.role],
)}
</span> </span>
</p> </p>
</div> </div>
@ -341,17 +313,15 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</CardContent> </CardContent>
</Card> </Card>
{isPlatformDocument && ( <div className="my-8 flex-row-reverse">
<div className="my-8 flex-row-reverse"> <div className="flex items-end justify-end gap-x-4">
<div className="flex items-end justify-end gap-x-4"> <p className="flex-shrink-0 text-sm font-medium print:text-xs">
<p className="flex-shrink-0 text-sm font-medium print:text-xs"> {_(msg`Signing certificate provided by`)}:
{_(msg`Signing certificate provided by`)}: </p>
</p>
<BrandingLogo className="max-h-6 print:max-h-4" /> <BrandingLogo className="max-h-6 print:max-h-4" />
</div>
</div> </div>
)} </div>
</div> </div>
); );
} }

View File

@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { PlusIcon } from 'lucide-react'; import { PlusIcon } from 'lucide-react';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse } from 'react-router'; import { Link, Outlet } from 'react-router';
import LogoIcon from '@documenso/assets/logo_icon.png'; import LogoIcon from '@documenso/assets/logo_icon.png';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
@ -16,8 +16,6 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { appMetaTags } from '~/utils/meta'; import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() { export function meta() {
return appMetaTags('Profile'); return appMetaTags('Profile');
} }
@ -98,9 +96,7 @@ export default function PublicProfileLayout() {
); );
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary() {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
const errorCodeMap = { const errorCodeMap = {
404: { 404: {
subHeading: msg`404 Profile not found`, subHeading: msg`404 Profile not found`,
@ -111,7 +107,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return ( return (
<GenericErrorLayout <GenericErrorLayout
errorCode={errorCode}
errorCodeMap={errorCodeMap} errorCodeMap={errorCodeMap}
secondaryButton={null} secondaryButton={null}
primaryButton={ primaryButton={

View File

@ -1,6 +1,6 @@
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse } from 'react-router'; import { Link, Outlet } from 'react-router';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -8,8 +8,6 @@ import { Button } from '@documenso/ui/primitives/button';
import { Header as AuthenticatedHeader } from '~/components/general/app-header'; import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { GenericErrorLayout } from '~/components/general/generic-error-layout'; import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import type { Route } from './+types/_layout';
/** /**
* A layout to handle scenarios where the user is a recipient of a given resource * A layout to handle scenarios where the user is a recipient of a given resource
* where we do not care whether they are authenticated or not. * where we do not care whether they are authenticated or not.
@ -32,12 +30,9 @@ export default function RecipientLayout() {
); );
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary() {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
return ( return (
<GenericErrorLayout <GenericErrorLayout
errorCode={errorCode}
secondaryButton={null} secondaryButton={null}
primaryButton={ primaryButton={
<Button asChild className="w-32"> <Button asChild className="w-32">

View File

@ -79,14 +79,7 @@ export default function DirectTemplatePage() {
const { template, directTemplateRecipient } = data; const { template, directTemplateRecipient } = data;
return ( return (
<DocumentSigningProvider <DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
email={user?.email}
fullName={user?.name}
signature={user?.signature}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
>
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={template.authOptions} documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient} recipient={directTemplateRecipient}

View File

@ -1,5 +1,5 @@
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { Clock8 } from 'lucide-react'; import { Clock8 } from 'lucide-react';
import { Link, redirect } from 'react-router'; import { Link, redirect } from 'react-router';
import { getOptionalLoaderContext } from 'server/utils/get-loader-session'; import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
@ -13,7 +13,6 @@ import { viewedDocument } from '@documenso/lib/server-only/document/viewed-docum
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
@ -73,24 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
? await getRecipientsForAssistant({ ? await getRecipientsForAssistant({
token, token,
}) })
: [recipient]; : [];
if (
document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL &&
recipient.role !== RecipientRole.ASSISTANT
) {
const nextPendingRecipient = await getNextPendingRecipient({
documentId: document.id,
currentRecipientId: recipient.id,
});
if (nextPendingRecipient) {
allRecipients.push({
...nextPendingRecipient,
fields: [],
});
}
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions, documentAuth: document.authOptions,
@ -178,7 +160,7 @@ export default function SigningPage() {
recipientWithFields, recipientWithFields,
} = data; } = data;
if (document.deletedAt || document.status === DocumentStatus.REJECTED) { if (document.deletedAt) {
return ( return (
<div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24"> <div className="-mx-4 flex max-w-[100vw] flex-col items-center overflow-x-hidden px-4 pt-16 md:-mx-8 md:px-8 lg:pt-16 xl:pt-24">
<SigningCard3D <SigningCard3D
@ -233,9 +215,6 @@ export default function SigningPage() {
email={recipient.email} email={recipient.email}
fullName={user?.email === recipient.email ? user?.name : recipient.name} fullName={user?.email === recipient.email ? user?.name : recipient.name}
signature={user?.email === recipient.email ? user?.signature : undefined} signature={user?.email === recipient.email ? user?.signature : undefined}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
> >
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={document.authOptions} documentAuthOptions={document.authOptions}

View File

@ -17,7 +17,6 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import DocumentDialog from '@documenso/ui/components/document/document-dialog'; import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
@ -206,12 +205,12 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} /> <DocumentShareButton documentId={document.id} token={recipient.token} />
{isDocumentCompleted(document.status) ? ( {document.status === DocumentStatus.COMPLETED ? (
<DocumentDownloadButton <DocumentDownloadButton
className="flex-1" className="flex-1"
fileName={document.title} fileName={document.title}
documentData={document.documentData} documentData={document.documentData}
disabled={!isDocumentCompleted(document.status)} disabled={document.status !== DocumentStatus.COMPLETED}
/> />
) : ( ) : (
<DocumentDialog <DocumentDialog
@ -269,7 +268,7 @@ export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentComple
const { revalidate } = useRevalidator(); const { revalidate } = useRevalidator();
useEffect(() => { useEffect(() => {
if (isDocumentCompleted(document.status)) { if (document.status === DocumentStatus.COMPLETED) {
return; return;
} }

View File

@ -19,30 +19,18 @@ const posthogProxy = async (request: Request) => {
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
headers.set('host', hostname); headers.set('host', hostname);
const fetchOptions: RequestInit = { const response = await fetch(newUrl, {
method: request.method, method: request.method,
headers, headers,
redirect: 'follow', body: request.body,
}; // @ts-expect-error - Not really sure about this
duplex: 'half',
if (!['GET', 'HEAD'].includes(request.method)) { });
fetchOptions.body = request.body;
// @ts-expect-error - It should exist
fetchOptions.duplex = 'half';
}
const response = await fetch(newUrl, fetchOptions);
const responseHeaders = new Headers(response.headers);
responseHeaders.delete('content-encoding');
responseHeaders.delete('content-length');
responseHeaders.delete('transfer-encoding');
responseHeaders.delete('cookie');
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: responseHeaders, headers: response.headers,
}); });
}; };

View File

@ -131,14 +131,7 @@ export default function EmbedDirectTemplatePage() {
} = useSuperLoaderData<typeof loader>(); } = useSuperLoaderData<typeof loader>();
return ( return (
<DocumentSigningProvider <DocumentSigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
email={user?.email}
fullName={user?.name}
signature={user?.signature}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
>
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={template.authOptions} documentAuthOptions={template.authOptions}
recipient={recipient} recipient={recipient}
@ -152,9 +145,7 @@ export default function EmbedDirectTemplatePage() {
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
metadata={template.templateMeta} metadata={template.templateMeta}
hidePoweredBy={ hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
}
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument} allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
/> />
</DocumentSigningRecipientProvider> </DocumentSigningRecipientProvider>

View File

@ -1,4 +1,4 @@
import { RecipientRole } from '@prisma/client'; import { DocumentStatus, RecipientRole } from '@prisma/client';
import { data } from 'react-router'; import { data } from 'react-router';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -8,14 +8,12 @@ import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-ent
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page'; import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
@ -34,7 +32,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request); const { user } = await getOptionalSession(request);
const [document, fields, recipient, completedFields] = await Promise.all([ const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
userId: user?.id, userId: user?.id,
@ -42,7 +40,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}).catch(() => null), }).catch(() => null),
getFieldsForToken({ token }), getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null), getRecipientByToken({ token }).catch(() => null),
getCompletedFieldsForToken({ token }).catch(() => []),
]); ]);
// `document.directLink` is always available but we're doing this to // `document.directLink` is always available but we're doing this to
@ -132,7 +129,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
allRecipients, allRecipients,
recipient, recipient,
fields, fields,
completedFields,
hidePoweredBy, hidePoweredBy,
isPlatformDocument, isPlatformDocument,
isEnterpriseDocument, isEnterpriseDocument,
@ -148,7 +144,6 @@ export default function EmbedSignDocumentPage() {
allRecipients, allRecipients,
recipient, recipient,
fields, fields,
completedFields,
hidePoweredBy, hidePoweredBy,
isPlatformDocument, isPlatformDocument,
isEnterpriseDocument, isEnterpriseDocument,
@ -160,9 +155,6 @@ export default function EmbedSignDocumentPage() {
email={recipient.email} email={recipient.email}
fullName={user?.email === recipient.email ? user?.name : recipient.name} fullName={user?.email === recipient.email ? user?.name : recipient.name}
signature={user?.email === recipient.email ? user?.signature : undefined} signature={user?.email === recipient.email ? user?.signature : undefined}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
> >
<DocumentSigningAuthProvider <DocumentSigningAuthProvider
documentAuthOptions={document.authOptions} documentAuthOptions={document.authOptions}
@ -175,12 +167,9 @@ export default function EmbedSignDocumentPage() {
documentData={document.documentData} documentData={document.documentData}
recipient={recipient} recipient={recipient}
fields={fields} fields={fields}
completedFields={completedFields}
metadata={document.documentMeta} metadata={document.documentMeta}
isCompleted={isDocumentCompleted(document.status)} isCompleted={document.status === DocumentStatus.COMPLETED}
hidePoweredBy={ hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
}
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument} allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
allRecipients={allRecipients} allRecipients={allRecipients}
/> />

View File

@ -12,7 +12,6 @@ const themeSessionStorage = createCookieSessionStorage({
secrets: ['insecure-secret-do-not-care'], secrets: ['insecure-secret-do-not-care'],
secure: useSecureCookies, secure: useSecureCookies,
domain: getCookieDomain(), domain: getCookieDomain(),
maxAge: 60 * 60 * 24 * 365,
}, },
}); });

View File

@ -14,5 +14,4 @@ export const ZSignDocumentEmbedDataSchema = ZBaseEmbedDataSchema.extend({
.transform((value) => value || undefined), .transform((value) => value || undefined),
lockName: z.boolean().optional().default(false), lockName: z.boolean().optional().default(false),
allowDocumentRejection: z.boolean().optional(), allowDocumentRejection: z.boolean().optional(),
showOtherRecipientsCompletedFields: z.boolean().optional(),
}); });

View File

@ -99,6 +99,5 @@
"vite": "^6.1.0", "vite": "^6.1.0",
"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.10.0-rc.4" }
}

View File

@ -33,7 +33,7 @@ FROM base AS installer
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
RUN apk add --no-cache jq RUN apk add --no-cache jq
# Required for node_modules/aws-crt # Required for node_modules/aws-crt
RUN apk add --no-cache make cmake g++ openssl bash RUN apk add --no-cache make cmake g++ openssl
WORKDIR /app WORKDIR /app

View File

@ -8,7 +8,6 @@ command -v docker >/dev/null 2>&1 || {
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")" SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)" GIT_SHA="$(git rev-parse HEAD)"
@ -16,39 +15,12 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION" echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA" echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker build -f "$SCRIPT_DIR/Dockerfile" \ docker build -f "$SCRIPT_DIR/Dockerfile" \
--progress=plain \ --progress=plain \
-t "documenso-base" \ -t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT" "$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"

View File

@ -9,11 +9,11 @@ SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")" MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
# Get the platform from environment variable or set to linux/amd64 if not set # Get the platform from environment variable or set to linux/amd64 if not set
# quote the string to prevent word splitting
if [ -z "$PLATFORM" ]; then if [ -z "$PLATFORM" ]; then
PLATFORM="linux/amd64" PLATFORM="linux/amd64"
fi fi
# Get Git information
APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')"
GIT_SHA="$(git rev-parse HEAD)" GIT_SHA="$(git rev-parse HEAD)"
@ -21,41 +21,14 @@ echo "Building docker image for monorepo at $MONOREPO_ROOT"
echo "App version: $APP_VERSION" echo "App version: $APP_VERSION"
echo "Git SHA: $GIT_SHA" echo "Git SHA: $GIT_SHA"
# Build with temporary base tag
docker buildx build \ docker buildx build \
-f "$SCRIPT_DIR/Dockerfile" \ -f "$SCRIPT_DIR/Dockerfile" \
--platform=$PLATFORM \ --platform=$PLATFORM \
--progress=plain \ --progress=plain \
-t "documenso-base" \ -t "documenso/documenso:latest" \
-t "documenso/documenso:$GIT_SHA" \
-t "documenso/documenso:$APP_VERSION" \
-t "ghcr.io/documenso/documenso:latest" \
-t "ghcr.io/documenso/documenso:$GIT_SHA" \
-t "ghcr.io/documenso/documenso:$APP_VERSION" \
"$MONOREPO_ROOT" "$MONOREPO_ROOT"
# Handle repository tagging
if [ ! -z "$DOCKER_REPOSITORY" ]; then
echo "Using custom repository: $DOCKER_REPOSITORY"
# Add tags for custom repository
docker tag "documenso-base" "$DOCKER_REPOSITORY:latest"
docker tag "documenso-base" "$DOCKER_REPOSITORY:$GIT_SHA"
# Add version tag if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "$DOCKER_REPOSITORY:$APP_VERSION"
fi
else
echo "Using default repositories: dockerhub and ghcr.io"
# Add tags for both default repositories
docker tag "documenso-base" "documenso/documenso:latest"
docker tag "documenso-base" "documenso/documenso:$GIT_SHA"
docker tag "documenso-base" "ghcr.io/documenso/documenso:latest"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$GIT_SHA"
# Add version tags if available
if [ ! -z "$APP_VERSION" ] && [ "$APP_VERSION" != "undefined" ]; then
docker tag "documenso-base" "documenso/documenso:$APP_VERSION"
docker tag "documenso-base" "ghcr.io/documenso/documenso:$APP_VERSION"
fi
fi
# Remove the temporary base tag
docker rmi "documenso-base"

5
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.10.0-rc.4", "version": "1.9.0-rc.11",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.10.0-rc.4", "version": "1.9.0-rc.11",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@ -95,7 +95,6 @@
}, },
"apps/remix": { "apps/remix": {
"name": "@documenso/remix", "name": "@documenso/remix",
"version": "1.10.0-rc.4",
"dependencies": { "dependencies": {
"@documenso/api": "*", "@documenso/api": "*",
"@documenso/assets": "*", "@documenso/assets": "*",

View File

@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.10.0-rc.4", "version": "1.9.0-rc.11",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",
@ -14,7 +14,7 @@
"prepare": "husky && husky install || true", "prepare": "husky && husky install || true",
"commitlint": "commitlint --edit", "commitlint": "commitlint --edit",
"clean": "turbo run clean && rimraf node_modules", "clean": "turbo run clean && rimraf node_modules",
"d": "npm run dx && npm run translate:compile && npm run dev", "d": "npm run dx && npm run dev",
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed", "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev && npm run prisma:seed",
"dx:up": "docker compose -f docker/development/compose.yml up -d", "dx:up": "docker compose -f docker/development/compose.yml up -d",
"dx:down": "docker compose -f docker/development/compose.yml down", "dx:down": "docker compose -f docker/development/compose.yml down",
@ -80,4 +80,4 @@
"trigger.dev": { "trigger.dev": {
"endpointId": "documenso-app" "endpointId": "documenso-app"
} }
} }

View File

@ -1,4 +1,4 @@
import { TsRestHttpError, fetchRequestHandler } from '@ts-rest/serverless/fetch'; import { fetchRequestHandler } from '@ts-rest/serverless/fetch';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { ApiContractV1 } from '@documenso/api/v1/contract'; import { ApiContractV1 } from '@documenso/api/v1/contract';
@ -29,12 +29,6 @@ tsRestHonoApp.mount('/', async (request) => {
request, request,
contract: ApiContractV1, contract: ApiContractV1,
router: ApiContractV1Implementation, router: ApiContractV1Implementation,
options: { options: {},
errorHandler: (err) => {
if (err instanceof TsRestHttpError && err.statusCode === 500) {
console.error(err);
}
},
},
}); });
}); });

View File

@ -1,5 +1,3 @@
import type { Prisma } from '@prisma/client';
import { DocumentDataType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch'; import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
@ -50,9 +48,15 @@ import {
getPresignGetUrl, getPresignGetUrl,
getPresignPostUrl, getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions'; } from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import {
DocumentDataType,
DocumentStatus,
SigningStatus,
TeamMemberRole,
} from '@documenso/prisma/client';
import { ApiContractV1 } from './contract'; import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated'; import { authenticatedMiddleware } from './middleware/authenticated';
@ -177,7 +181,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (!isDocumentCompleted(document.status)) { if (document.status !== DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { body: {
@ -323,11 +327,8 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
dateFormat: dateFormat?.value, dateFormat: dateFormat?.value,
redirectUrl: body.meta.redirectUrl, redirectUrl: body.meta.redirectUrl,
signingOrder: body.meta.signingOrder, signingOrder: body.meta.signingOrder,
allowDictateNextSigner: body.meta.allowDictateNextSigner,
language: body.meta.language, language: body.meta.language,
typedSignatureEnabled: body.meta.typedSignatureEnabled, typedSignatureEnabled: body.meta.typedSignatureEnabled,
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
drawSignatureEnabled: body.meta.drawSignatureEnabled,
distributionMethod: body.meta.distributionMethod, distributionMethod: body.meta.distributionMethod,
emailSettings: body.meta.emailSettings, emailSettings: body.meta.emailSettings,
requestMetadata: metadata, requestMetadata: metadata,
@ -584,7 +585,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
recipients: body.recipients, recipients: body.recipients,
prefillFields: body.prefillFields,
override: { override: {
title: body.title, title: body.title,
...body.meta, ...body.meta,
@ -673,7 +673,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (isDocumentCompleted(document.status)) { if (document.status === DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { body: {
@ -776,7 +776,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (isDocumentCompleted(document.status)) { if (document.status === DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { body: {
@ -867,7 +867,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (isDocumentCompleted(document.status)) { if (document.status === DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { body: {
@ -926,7 +926,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (isDocumentCompleted(document.status)) { if (document.status === DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { body: {
@ -991,7 +991,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (isDocumentCompleted(document.status)) { if (document.status === DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { message: 'Document is already completed' }, body: { message: 'Document is already completed' },
@ -1153,7 +1153,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (isDocumentCompleted(document.status)) { if (document.status === DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { body: {
@ -1241,7 +1241,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
}; };
} }
if (isDocumentCompleted(document.status)) { if (document.status === DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { body: {

View File

@ -1,10 +1,10 @@
import type { Team, User } from '@prisma/client';
import type { TsRestRequest } from '@ts-rest/serverless'; import type { TsRestRequest } from '@ts-rest/serverless';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Team, User } from '@documenso/prisma/client';
type B = { type B = {
// appRoute: any; // appRoute: any;

View File

@ -1,16 +1,4 @@
import { extendZodWithOpenApi } from '@anatine/zod-openapi'; import { extendZodWithOpenApi } from '@anatine/zod-openapi';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@ -23,7 +11,19 @@ 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 { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@documenso/prisma/client';
extendZodWithOpenApi(z); extendZodWithOpenApi(z);
@ -96,7 +96,7 @@ export const ZSendDocumentForSigningMutationSchema = z
'Whether to send completion emails when the document is fully signed. This will override the document email settings.', 'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
}), }),
}) })
.or(z.any().transform(() => ({ sendEmail: true, sendCompletionEmails: undefined }))); .or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema; export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
@ -155,11 +155,8 @@ export const ZCreateDocumentMutationSchema = z.object({
}), }),
redirectUrl: z.string(), redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
typedSignatureEnabled: z.boolean().optional().default(true), typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
drawSignatureEnabled: z.boolean().optional().default(true),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(), distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(), emailSettings: ZDocumentEmailSettingsSchema.optional(),
}) })
@ -221,7 +218,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(), dateFormat: z.string(),
redirectUrl: z.string(), redirectUrl: z.string(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(), signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(), language: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
}) })
.partial() .partial()
@ -289,12 +285,9 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
dateFormat: z.string(), dateFormat: z.string(),
redirectUrl: ZUrlSchema, redirectUrl: ZUrlSchema,
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean(),
language: z.enum(SUPPORTED_LANGUAGE_CODES), language: z.enum(SUPPORTED_LANGUAGE_CODES),
distributionMethod: z.nativeEnum(DocumentDistributionMethod), distributionMethod: z.nativeEnum(DocumentDistributionMethod),
typedSignatureEnabled: z.boolean(), typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
emailSettings: ZDocumentEmailSettingsSchema, emailSettings: ZDocumentEmailSettingsSchema,
}) })
.partial() .partial()
@ -306,7 +299,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = 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(),
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
}); });
export type TGenerateDocumentFromTemplateMutationSchema = z.infer< export type TGenerateDocumentFromTemplateMutationSchema = z.infer<

View File

@ -1,5 +1,4 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { import {
ZFindTeamMembersResponseSchema, ZFindTeamMembersResponseSchema,
@ -11,6 +10,7 @@ import {
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';

View File

@ -1,614 +0,0 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe('Template Field Prefill API v1', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Advanced Fields',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Number Field',
},
},
});
// Add RADIO field
const radioField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 5,
positionY: 25,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// Add CHECKBOX field
const checkboxField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.CHECKBOX,
page: 1,
positionX: 5,
positionY: 35,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'checkbox',
label: 'Checkbox Field',
values: [
{ id: 1, value: 'Check A', checked: false },
{ id: 2, value: 'Check B', checked: false },
],
},
},
});
// Add DROPDOWN field
const dropdownField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.DROPDOWN,
page: 1,
positionX: 5,
positionY: 45,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'dropdown',
label: 'Dropdown Field',
values: [{ value: 'Select A' }, { value: 'Select B' }],
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template with prefilled fields
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Prefilled Fields',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
prefillFields: [
{
id: textField.id,
type: 'text',
label: 'Prefilled Text',
value: 'This is prefilled text',
},
{
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
},
{
id: radioField.id,
type: 'radio',
label: 'Prefilled Radio',
value: 'Option A',
},
{
id: checkboxField.id,
type: 'checkbox',
label: 'Prefilled Checkbox',
value: ['Check A', 'Check B'],
},
{
id: dropdownField.id,
type: 'dropdown',
label: 'Prefilled Dropdown',
value: 'Select B',
},
],
},
},
);
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.documentId).toBeDefined();
// 9. Verify the document was created with prefilled fields
const document = await prisma.document.findUnique({
where: {
id: responseData.documentId,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify each field has the correct prefilled values
const documentTextField = document?.fields.find(
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Prefilled Text',
text: 'This is prefilled text',
});
const documentNumberField = document?.fields.find(
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
});
const documentRadioField = document?.fields.find(
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
);
expect(documentRadioField?.fieldMeta).toMatchObject({
type: 'radio',
label: 'Prefilled Radio',
});
// Check that the correct radio option is selected
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
const selectedRadioOption = radioValues.find((option) => option.checked);
expect(selectedRadioOption?.value).toBe('Option A');
const documentCheckboxField = document?.fields.find(
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
);
expect(documentCheckboxField?.fieldMeta).toMatchObject({
type: 'checkbox',
label: 'Prefilled Checkbox',
});
// Check that the correct checkbox options are selected
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
const checkedOptions = checkboxValues.filter((option) => option.checked);
expect(checkedOptions.length).toBe(2);
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
const documentDropdownField = document?.fields.find(
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
);
expect(documentDropdownField?.fieldMeta).toMatchObject({
type: 'dropdown',
label: 'Prefilled Dropdown',
defaultValue: 'Select B',
});
// 11. Sign in as the recipient and verify the prefilled fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
// Send the document to the recipient
const sendResponse = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: false,
},
},
);
expect(sendResponse.ok()).toBeTruthy();
expect(sendResponse.status()).toBe(200);
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the prefilled fields are visible with correct values
// Text field
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
// Checkbox field
await expect(page.getByText('Check A')).toBeVisible();
await expect(page.getByText('Check B')).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
// Dropdown field
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Default Fields',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Default Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Default Number Field',
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template without prefilled fields
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Default Fields',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
},
},
);
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.documentId).toBeDefined();
// 9. Verify the document was created with default fields
const document = await prisma.document.findUnique({
where: {
id: responseData.documentId,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify fields have their default values
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Default Text Field',
});
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Default Number Field',
});
// 11. Sign in as the recipient and verify the default fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
const sendResponse = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: false,
},
},
);
expect(sendResponse.ok()).toBeTruthy();
expect(sendResponse.status()).toBe(200);
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the default fields are visible with correct labels
await expect(page.getByText('Default Text Field')).toBeVisible();
await expect(page.getByText('Default Number Field')).toBeVisible();
});
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template for Invalid Test',
visibility: 'EVERYONE',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add a field to the template
const field = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 100,
positionY: 100,
width: 100,
height: 50,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// 6. Try to create a document with invalid prefill value
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Invalid Prefill',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
prefillFields: [
{
id: field.id,
type: 'radio',
label: 'Invalid Radio',
value: 'Non-existent Option', // This option doesn't exist
},
],
},
},
);
// 7. Verify the request fails with appropriate error
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
const errorData = await response.json();
expect(errorData.message).toContain('not found in options for RADIO field');
});
});

View File

@ -1,602 +0,0 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe('Template Field Prefill API v2', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Advanced Fields V2',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Number Field',
},
},
});
// Add RADIO field
const radioField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 5,
positionY: 25,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// Add CHECKBOX field
const checkboxField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.CHECKBOX,
page: 1,
positionX: 5,
positionY: 35,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'checkbox',
label: 'Checkbox Field',
values: [
{ id: 1, value: 'Check A', checked: false },
{ id: 2, value: 'Check B', checked: false },
],
},
},
});
// Add DROPDOWN field
const dropdownField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.DROPDOWN,
page: 1,
positionX: 5,
positionY: 45,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'dropdown',
label: 'Dropdown Field',
values: [{ value: 'Select A' }, { value: 'Select B' }],
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template with prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
prefillFields: [
{
id: textField.id,
type: 'text',
label: 'Prefilled Text',
value: 'This is prefilled text',
},
{
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
},
{
id: radioField.id,
type: 'radio',
label: 'Prefilled Radio',
value: 'Option A',
},
{
id: checkboxField.id,
type: 'checkbox',
label: 'Prefilled Checkbox',
value: ['Check A', 'Check B'],
},
{
id: dropdownField.id,
type: 'dropdown',
label: 'Prefilled Dropdown',
value: 'Select B',
},
],
},
});
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.id).toBeDefined();
// 9. Verify the document was created with prefilled fields
const document = await prisma.document.findUnique({
where: {
id: responseData.id,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify each field has the correct prefilled values
const documentTextField = document?.fields.find(
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Prefilled Text',
text: 'This is prefilled text',
});
const documentNumberField = document?.fields.find(
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
});
const documentRadioField = document?.fields.find(
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
);
expect(documentRadioField?.fieldMeta).toMatchObject({
type: 'radio',
label: 'Prefilled Radio',
});
// Check that the correct radio option is selected
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
const selectedRadioOption = radioValues.find((option) => option.checked);
expect(selectedRadioOption?.value).toBe('Option A');
const documentCheckboxField = document?.fields.find(
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
);
expect(documentCheckboxField?.fieldMeta).toMatchObject({
type: 'checkbox',
label: 'Prefilled Checkbox',
});
// Check that the correct checkbox options are selected
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
const checkedOptions = checkboxValues.filter((option) => option.checked);
expect(checkedOptions.length).toBe(2);
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
const documentDropdownField = document?.fields.find(
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
);
expect(documentDropdownField?.fieldMeta).toMatchObject({
type: 'dropdown',
label: 'Prefilled Dropdown',
defaultValue: 'Select B',
});
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
documentId: document?.id,
meta: {
subject: 'Test Subject',
message: 'Test Message',
},
},
});
await expect(sendResponse.ok()).toBeTruthy();
await expect(sendResponse.status()).toBe(200);
// 11. Sign in as the recipient and verify the prefilled fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the prefilled fields are visible with correct values
// Text field
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
// Checkbox field
await expect(page.getByText('Check A')).toBeVisible();
await expect(page.getByText('Check B')).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
// Dropdown field
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Default Fields V2',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Default Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Default Number Field',
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template without prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
},
});
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.id).toBeDefined();
// 9. Verify the document was created with default fields
const document = await prisma.document.findUnique({
where: {
id: responseData.id,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify fields have their default values
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Default Text Field',
});
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Default Number Field',
});
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
documentId: document?.id,
meta: {
subject: 'Test Subject',
message: 'Test Message',
},
},
});
await expect(sendResponse.ok()).toBeTruthy();
await expect(sendResponse.status()).toBe(200);
// 11. Sign in as the recipient and verify the default fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
await expect(page.getByText('This is prefilled')).not.toBeVisible();
});
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template for Invalid Test V2',
visibility: 'EVERYONE',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add a field to the template
const field = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 100,
positionY: 100,
width: 100,
height: 50,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// 7. Try to create a document with invalid prefill value
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
prefillFields: [
{
id: field.id,
type: 'radio',
label: 'Invalid Radio',
value: 'Non-existent Option', // This option doesn't exist
},
],
},
});
// 8. Verify the request fails with appropriate error
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
const errorData = await response.json();
expect(errorData.message).toContain('not found in options for RADIO field');
});
});

View File

@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth'; import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { import {
createDocumentAuthOptions, createDocumentAuthOptions,
createRecipientAuthOptions, createRecipientAuthOptions,
} from '@documenso/lib/utils/document-auth'; } from '@documenso/lib/utils/document-auth';
import { FieldType } from '@documenso/prisma/client';
import { import {
seedPendingDocumentNoFields, seedPendingDocumentNoFields,
seedPendingDocumentWithFullFields, seedPendingDocumentWithFullFields,
@ -210,7 +210,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}), }),
}, },
], ],
fields: [FieldType.DATE, FieldType.SIGNATURE], fields: [FieldType.DATE],
}); });
for (const recipient of recipients) { for (const recipient of recipients) {
@ -246,9 +246,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
}); });
} }
if (fields.some((field) => field.type === FieldType.SIGNATURE)) { await signSignaturePad(page);
await signSignaturePad(page);
}
for (const field of fields) { for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
@ -309,7 +307,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
}), }),
}, },
], ],
fields: [FieldType.DATE, FieldType.SIGNATURE], fields: [FieldType.DATE],
updateDocumentOptions: { updateDocumentOptions: {
authOptions: createDocumentAuthOptions({ authOptions: createDocumentAuthOptions({
globalAccessAuth: null, globalAccessAuth: null,
@ -351,9 +349,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
}); });
} }
if (fields.some((field) => field.type === FieldType.SIGNATURE)) { await signSignaturePad(page);
await signSignaturePad(page);
}
for (const field of fields) { for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();

View File

@ -1,390 +0,0 @@
import { expect, test } from '@playwright/test';
import {
DocumentSigningOrder,
DocumentStatus,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature';
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
page,
}) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const thirdSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner, thirdSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const firstRecipient = recipients[0];
const { token, fields } = firstRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing and update next recipient
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Update next recipient
await page.locator('button').filter({ hasText: 'Update Recipient' }).click();
await page.waitForTimeout(1000);
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name').fill('New Recipient');
await dialog.getByLabel('Email').fill('new.recipient@example.com');
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// First recipient should be completed
const updatedFirstRecipient = updatedDocument.recipients[0];
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
// Second recipient should be the new recipient
const updatedSecondRecipient = updatedDocument.recipients[1];
expect(updatedSecondRecipient.name).toBe('New Recipient');
expect(updatedSecondRecipient.email).toBe('new.recipient@example.com');
expect(updatedSecondRecipient.signingOrder).toBe(2);
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const firstRecipient = recipients[0];
const { token, fields } = firstRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// First recipient should be completed
const updatedFirstRecipient = updatedDocument.recipients[0];
expect(updatedFirstRecipient.signingStatus).toBe(SigningStatus.SIGNED);
// Second recipient should remain unchanged
const updatedSecondRecipient = updatedDocument.recipients[1];
expect(updatedSecondRecipient.email).toBe(secondSigner.email);
expect(updatedSecondRecipient.signingOrder).toBe(2);
expect(updatedSecondRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.PARALLEL,
},
update: {
allowDictateNextSigner: false,
signingOrder: DocumentSigningOrder.PARALLEL,
},
},
},
},
});
// Test both recipients can sign in parallel
for (const recipient of recipients) {
const { token, fields } = recipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete signing
await page.getByRole('button', { name: 'Complete' }).click();
// Verify next recipient UI is not shown in parallel flow
await expect(
page.getByText('The next recipient to sign this document will be'),
).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Update Recipient' })).not.toBeVisible();
// Submit and verify completion
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
}
// Verify final document and recipient states
await expect(async () => {
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should be completed since all recipients have signed
expect(updatedDocument.status).toBe(DocumentStatus.COMPLETED);
// All recipients should be completed
for (const recipient of updatedDocument.recipients) {
expect(recipient.signingStatus).toBe(SigningStatus.SIGNED);
}
}).toPass();
});
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
page,
}) => {
const user = await seedUser();
const assistant = await seedUser();
const signer = await seedUser();
const thirdSigner = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: [assistant, signer, thirdSigner],
recipientsCreateOptions: [
{ signingOrder: 1, role: RecipientRole.ASSISTANT },
{ signingOrder: 2, role: RecipientRole.SIGNER },
{ signingOrder: 3, role: RecipientRole.SIGNER },
],
updateDocumentOptions: {
documentMeta: {
upsert: {
create: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
update: {
allowDictateNextSigner: true,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
},
},
},
});
const assistantRecipient = recipients[0];
const { token, fields } = assistantRecipient;
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Assist Document' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByRole('radio', { name: assistantRecipient.name }).click();
// Fill in all fields
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.SIGNATURE) {
await signDirectSignaturePad(page);
await page.getByRole('button', { name: 'Sign', exact: true }).click();
}
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
// Complete assisting and update next recipient
await page.getByRole('button', { name: 'Continue' }).click();
// Verify next recipient info is shown
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText('The next recipient to sign this document will be')).toBeVisible();
// Use dialog context to ensure we're targeting the correct form fields
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: 'Update Recipient' }).click();
await dialog.getByLabel('Name').fill('New Signer');
await dialog.getByLabel('Email').fill('new.signer@example.com');
// Submit and verify completion
await page.getByRole('button', { name: /Continue|Proceed/i }).click();
await page.waitForURL(`${signUrl}/complete`);
// Verify document and recipient states
await expect(async () => {
const updatedDocument = await prisma.document.findUniqueOrThrow({
where: { id: document.id },
include: {
recipients: {
orderBy: { signingOrder: 'asc' },
},
},
});
// Document should still be pending as there are more recipients
expect(updatedDocument.status).toBe(DocumentStatus.PENDING);
// Assistant should be completed
const updatedAssistant = updatedDocument.recipients[0];
expect(updatedAssistant.signingStatus).toBe(SigningStatus.SIGNED);
expect(updatedAssistant.role).toBe(RecipientRole.ASSISTANT);
// Second recipient should be the new signer
const updatedSigner = updatedDocument.recipients[1];
expect(updatedSigner.name).toBe('New Signer');
expect(updatedSigner.email).toBe('new.signer@example.com');
expect(updatedSigner.signingOrder).toBe(2);
expect(updatedSigner.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(updatedSigner.role).toBe(RecipientRole.SIGNER);
// Third recipient should remain unchanged
const thirdRecipient = updatedDocument.recipients[2];
expect(thirdRecipient.email).toBe(thirdSigner.email);
expect(thirdRecipient.signingOrder).toBe(3);
expect(thirdRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
expect(thirdRecipient.role).toBe(RecipientRole.SIGNER);
}).toPass();
});

View File

@ -1,16 +1,16 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { import {
DocumentSigningOrder, DocumentSigningOrder,
DocumentStatus, DocumentStatus,
FieldType, FieldType,
RecipientRole, RecipientRole,
SigningStatus, SigningStatus,
} from '@prisma/client'; } from '@documenso/prisma/client';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import { import {
seedBlankDocument, seedBlankDocument,
seedPendingDocumentWithFullFields, seedPendingDocumentWithFullFields,
@ -377,9 +377,7 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true'); await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
} }
await page await page.getByRole('button', { name: 'Complete' }).click();
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' })
.click();
await page await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' }) .getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
.click(); .click();
@ -449,7 +447,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
const { status } = await getDocumentByToken(token); const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING); expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Approve' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible(); await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
await page.getByRole('button', { name: 'Approve' }).click(); await page.getByRole('button', { name: 'Approve' }).click();

View File

@ -1,10 +1,10 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents'; import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
@ -222,10 +222,7 @@ test.describe('Signing Certificate Tests', () => {
// Toggle signing certificate setting // Toggle signing certificate setting
await page.getByLabel('Include the Signing Certificate in the Document').click(); await page.getByLabel('Include the Signing Certificate in the Document').click();
await page await page.getByRole('button', { name: /Save/ }).first().click();
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
@ -239,10 +236,7 @@ test.describe('Signing Certificate Tests', () => {
// Toggle the setting back to true // Toggle the setting back to true
await page.getByLabel('Include the Signing Certificate in the Document').click(); await page.getByLabel('Include the Signing Certificate in the Document').click();
await page await page.getByRole('button', { name: /Save/ }).first().click();
.getByRole('button', { name: /Update/ })
.first()
.click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);

View File

@ -1,28 +1,40 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
/**
* Will open the signature pad dialog and sign it.
*/
export const signSignaturePad = async (page: Page) => { export const signSignaturePad = async (page: Page) => {
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page.getByTestId('signature-pad-dialog-button').click(); const canvas = page.getByTestId('signature-pad');
// Click type tab const box = await canvas.boundingBox();
await page.getByRole('tab', { name: 'Type' }).click();
await page.getByTestId('signature-pad-type-input').fill('Signature');
// Click Next button if (!box) {
await page.getByRole('button', { name: 'Next' }).click(); throw new Error('Signature pad not found');
}; }
/** // Calculate center point
* For when the signature pad is already open. const centerX = box.x + box.width / 2;
*/ const centerY = box.y + box.height / 2;
export const signDirectSignaturePad = async (page: Page) => {
await page.waitForTimeout(200); // Calculate square size (making it slightly smaller than the canvas)
const squareSize = Math.min(box.width, box.height) * 0.4; // 40% of the smallest dimension
// Click type tab
await page.getByRole('tab', { name: 'Type' }).click(); // Move to center
await page.getByTestId('signature-pad-type-input').fill('Signature'); await page.mouse.move(centerX, centerY);
await page.mouse.down();
// Draw square clockwise from center
// Move right
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
// Move down
await page.mouse.move(centerX + squareSize, centerY + squareSize, { steps: 10 });
// Move left
await page.mouse.move(centerX - squareSize, centerY + squareSize, { steps: 10 });
// Move up
await page.mouse.move(centerX - squareSize, centerY - squareSize, { steps: 10 });
// Move right
await page.mouse.move(centerX + squareSize, centerY - squareSize, { steps: 10 });
// Move down to close the square
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
await page.mouse.up();
}; };

View File

@ -56,7 +56,6 @@ test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
// Go back to public profile page. // Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`); await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
await page.getByRole('switch').click(); await page.getByRole('switch').click();
await page.waitForTimeout(1000);
// Assert values. // Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`); await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
@ -128,7 +127,6 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
// Go back to public profile page. // Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`); await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
await page.getByRole('switch').click(); await page.getByRole('switch').click();
await page.waitForTimeout(1000);
// Assert values. // Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`); await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents'; import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents'; import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams'; import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';

View File

@ -23,7 +23,7 @@ test('[TEAMS]: update the default document visibility in the team global setting
// !: Brittle selector // !: Brittle selector
await page.getByRole('combobox').first().click(); await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Admin' }).click(); await page.getByRole('option', { name: 'Admin' }).click();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save' }).first().click();
const toast = page.locator('li[role="status"][data-state="open"]').first(); const toast = page.locator('li[role="status"][data-state="open"]').first();
await expect(toast).toBeVisible(); await expect(toast).toBeVisible();
@ -47,7 +47,7 @@ test('[TEAMS]: update the sender details in the team global settings', async ({
await expect(checkbox).toBeChecked(); await expect(checkbox).toBeChecked();
await page.getByRole('button', { name: 'Update' }).first().click(); await page.getByRole('button', { name: 'Save' }).first().click();
const toast = page.locator('li[role="status"][data-state="open"]').first(); const toast = page.locator('li[role="status"][data-state="open"]').first();
await expect(toast).toBeVisible(); await expect(toast).toBeVisible();

View File

@ -1,186 +0,0 @@
import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import {
seedTeamDocumentWithMeta,
seedTeamDocuments,
seedTeamTemplateWithMeta,
} from '@documenso/prisma/seed/documents';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEAMS]: check that default team signature settings are all enabled', async ({ page }) => {
const { team } = await seedTeamDocuments();
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
// Verify that the default created team settings has all signatures enabled
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
const document = await seedTeamDocumentWithMeta(team);
// Create a document and check the settings
await page.goto(`/t/${team.url}/documents/${document.id}/edit`);
// Verify that the settings match
await page.getByRole('button', { name: 'Advanced Options' }).click();
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
// Go to document and check that the signatured tabs are correct.
await page.goto(`/sign/${document.recipients[0].token}`);
await page.getByTestId('signature-pad-dialog-button').click();
// Check the tab values
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
});
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
const { team } = await seedTeamDocuments();
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
// Clear all selected items.
for (const tab of allTabs) {
const item = page.getByRole('option', { name: tab });
const isSelected = (await item.innerHTML()).includes('opacity-100');
if (isSelected) {
await item.click();
}
}
// Selected wanted items.
for (const tab of tabs) {
const item = page.getByRole('option', { name: tab });
await item.click();
}
await page.getByRole('button', { name: 'Update' }).first().click();
const document = await seedTeamDocumentWithMeta(team);
// Go to document and check that the signatured tabs are correct.
await page.goto(`/sign/${document.recipients[0].token}`);
await page.getByTestId('signature-pad-dialog-button').click();
// Check the tab values
for (const tab of allTabs) {
if (tabs.includes(tab)) {
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
} else {
await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
}
}
}
});
test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
const { team } = await seedTeamDocuments();
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/preferences`);
// Update combobox to have the correct tabs
await page.getByTestId('signature-types-combobox').click();
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
// Clear all selected items.
for (const tab of allTabs) {
const item = page.getByRole('option', { name: tab });
const isSelected = (await item.innerHTML()).includes('opacity-100');
if (isSelected) {
await item.click();
}
}
// Selected wanted items.
for (const tab of tabs) {
const item = page.getByRole('option', { name: tab });
await item.click();
}
await page.getByRole('button', { name: 'Update' }).first().click();
// Wait for finish
await page.getByText('Document preferences updated').waitFor({ state: 'visible' });
await page.waitForTimeout(1000);
const template = await seedTeamTemplateWithMeta(team);
await page.goto(`/t/${team.url}/templates/${template.id}`);
await page.getByRole('button', { name: 'Use' }).click();
// Check the send document checkbox to true
await page.getByLabel('Send document').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await page.waitForTimeout(1000);
const document = await prisma.document.findFirst({
where: {
templateId: template.id,
},
include: {
documentMeta: true,
},
});
// Test kinda flaky, debug here.
// console.log({
// tabs,
// typedSignatureEnabled: document?.documentMeta?.typedSignatureEnabled,
// uploadSignatureEnabled: document?.documentMeta?.uploadSignatureEnabled,
// drawSignatureEnabled: document?.documentMeta?.drawSignatureEnabled,
// });
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
}
});

View File

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@ -12,7 +12,7 @@ import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => { test.describe('[EE_ONLY]', () => {
const enterprisePriceId = ''; const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => { test.beforeEach(() => {
test.skip( test.skip(

View File

@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentDataType, TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams'; import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@ -15,7 +15,7 @@ import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
const enterprisePriceId = ''; const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
// Create a temporary PDF file for testing // Create a temporary PDF file for testing
function createTempPdfFile() { function createTempPdfFile() {

View File

@ -298,7 +298,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({
await page.goto(formatDirectTemplatePath(template.directLink?.token || '')); await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail()); await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();

View File

@ -4,7 +4,6 @@ import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-emai
import { seedUser } from '@documenso/prisma/seed/users'; import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication'; import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
test('[USER] update full name', async ({ page }) => { test('[USER] update full name', async ({ page }) => {
const user = await seedUser(); const user = await seedUser();
@ -13,7 +12,7 @@ test('[USER] update full name', async ({ page }) => {
await page.getByLabel('Full Name').fill('John Doe'); await page.getByLabel('Full Name').fill('John Doe');
await signSignaturePad(page); await page.getByPlaceholder('Type your signature').fill('John Doe');
await page.getByRole('button', { name: 'Update profile' }).click(); await page.getByRole('button', { name: 'Update profile' }).click();

View File

@ -1,4 +1,3 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { OAuth2Client, decodeIdToken } from 'arctic'; import { OAuth2Client, decodeIdToken } from 'arctic';
import type { Context } from 'hono'; import type { Context } from 'hono';
import { deleteCookie } from 'hono/cookie'; import { deleteCookie } from 'hono/cookie';
@ -7,6 +6,7 @@ import { nanoid } from 'nanoid';
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 { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import type { OAuthClientOptions } from '../../config'; import type { OAuthClientOptions } from '../../config';
import { AuthenticationErrorCode } from '../errors/error-codes'; import { AuthenticationErrorCode } from '../errors/error-codes';

View File

@ -1,6 +1,5 @@
import { sValidator } from '@hono/standard-validator'; import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt'; import { compare } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { z } from 'zod'; import { z } from 'zod';
@ -23,6 +22,7 @@ import { updatePassword } from '@documenso/lib/server-only/user/update-password'
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email'; import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AuthenticationErrorCode } from '../lib/errors/error-codes'; import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { getCsrfCookie } from '../lib/session/session-cookies'; import { getCsrfCookie } from '../lib/session/session-cookies';

View File

@ -1,8 +1,8 @@
import { DocumentSource, SubscriptionStatus } from '@prisma/client';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentSource, SubscriptionStatus } from '@documenso/prisma/client';
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts'; import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';

View File

@ -1,8 +1,7 @@
import type { User } from '@prisma/client';
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing'; import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { onSubscriptionUpdated } from './webhook/on-subscription-updated'; import { onSubscriptionUpdated } from './webhook/on-subscription-updated';

View File

@ -4,14 +4,15 @@ import { stripe } from '@documenso/lib/server-only/stripe';
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE]; type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => { export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan; const planTypes = typeof plan === 'string' ? [plan] : plan;
const prices = await stripe.prices.list({ const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
const { data: prices } = await stripe.prices.search({
query,
expand: ['data.product'], expand: ['data.product'],
limit: 100, limit: 100,
}); });
return prices.data.filter( return prices.filter((price) => price.type === 'recurring');
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
);
}; };

View File

@ -1,10 +1,10 @@
import { type Subscription, type Team, type User } from '@prisma/client';
import type Stripe from 'stripe'; import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing'; import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods'; import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getTeamPrices } from './get-team-prices'; import { getTeamPrices } from './get-team-prices';

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