Compare commits
38 Commits
fix/checkb
...
feat/add-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813d02d36b | ||
|
|
bad71d92c5 | ||
|
|
3fbf6c5842 | ||
|
|
4156f2afce | ||
|
|
55b7697316 | ||
|
|
67bbb6c6f4 | ||
|
|
63a4bab0fe | ||
|
|
9f17c1e48e | ||
|
|
91ae818213 | ||
|
|
a0ace803cf | ||
|
|
b3db3be8e9 | ||
|
|
44cdbeecb4 | ||
|
|
7214965c0c | ||
|
|
8d6bf91d12 | ||
|
|
fec078081b | ||
|
|
c646afcd97 | ||
|
|
63d990ce8d | ||
|
|
aa7d6b28a4 | ||
|
|
b990532633 | ||
|
|
65be37514f | ||
|
|
0df29fce36 | ||
|
|
ba5b7ce480 | ||
|
|
422770a8c7 | ||
|
|
083a706373 | ||
|
|
db326cb4a9 | ||
|
|
d664f571d6 | ||
|
|
7c38970ee8 | ||
|
|
e08d62c844 | ||
|
|
25bb6ffe77 | ||
|
|
e79d762710 | ||
|
|
d970976299 | ||
|
|
3dce814ab2 | ||
|
|
ad520bb032 | ||
|
|
596d30e2e5 | ||
|
|
6474b4a524 | ||
|
|
5b4db51051 | ||
|
|
cf58c80e31 | ||
|
|
11dbb8873e |
@@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
|
|||||||
<EmbedDirectTemplate
|
<EmbedDirectTemplate
|
||||||
token={token}
|
token={token}
|
||||||
cssVars={{
|
cssVars={{
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
`}
|
`}
|
||||||
// CSS Variables
|
// CSS Variables
|
||||||
cssVars={{
|
cssVars={{
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
}}
|
}}
|
||||||
// Dark Mode Control
|
// Dark Mode Control
|
||||||
darkModeDisabled={true}
|
darkModeDisabled={true}
|
||||||
|
|||||||
@@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
colorPrimary: '#0000FF',
|
primary: '#0000FF',
|
||||||
colorBackground: '#F5F5F5',
|
background: '#F5F5F5',
|
||||||
borderRadius: '8px',
|
radius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ Our new API V2 supports the following typed SDKs:
|
|||||||
- [Python](https://github.com/documenso/sdk-python)
|
- [Python](https://github.com/documenso/sdk-python)
|
||||||
- [Go](https://github.com/documenso/sdk-go)
|
- [Go](https://github.com/documenso/sdk-go)
|
||||||
|
|
||||||
|
<Callout type="info">
|
||||||
|
For the staging API, please use the following base URL:
|
||||||
|
`https://stg-app.documenso.dev/api/v2-beta/`
|
||||||
|
</Callout>
|
||||||
|
|
||||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||||
|
|
||||||
📖 [Documentation](https://documen.so/api-v2-docs)
|
📖 [Documentation](https://documen.so/api-v2-docs)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { match } from 'ts-pattern';
|
import { P, 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(DocumentStatus.COMPLETED, () => (
|
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p>
|
<p>
|
||||||
<Trans>By deleting this document, the following will occur:</Trans>
|
<Trans>By deleting this document, the following will occur:</Trans>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
@@ -97,7 +97,7 @@ export const DocumentDuplicateDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||||
<LazyPDFViewer key={document?.id} documentData={documentData} />
|
<PDFViewer key={document?.id} documentData={documentData} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
128
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal file
128
apps/remix/app/components/dialogs/folder-create-dialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { FolderPlusIcon } from 'lucide-react';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
|
||||||
|
status: true,
|
||||||
|
period: true,
|
||||||
|
page: true,
|
||||||
|
perPage: true,
|
||||||
|
query: true,
|
||||||
|
}).extend({
|
||||||
|
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
|
||||||
|
folderId: z
|
||||||
|
.string()
|
||||||
|
.transform((val) => parseInt(val, 10))
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateFolderDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const CreateFolderDialog = ({ trigger, ...props }: CreateFolderDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
|
||||||
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
|
|
||||||
|
const findDocumentSearchParams = useMemo(
|
||||||
|
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
|
||||||
|
[searchParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentFolderId = findDocumentSearchParams.folderId;
|
||||||
|
|
||||||
|
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
|
||||||
|
|
||||||
|
const handleCreateFolder = async () => {
|
||||||
|
if (!newFolderName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Folder name is required',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createFolder({
|
||||||
|
name: newFolderName,
|
||||||
|
parentId: currentFolderId,
|
||||||
|
});
|
||||||
|
|
||||||
|
setNewFolderName('');
|
||||||
|
setIsCreateFolderOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Folder created successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating folder:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to create folder',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="outline" className="flex items-center space-x-2">
|
||||||
|
<FolderPlusIcon className="h-4 w-4" />
|
||||||
|
<span>Create Folder</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Folder</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a name for your new folder. Folders help you organize your documents.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Label htmlFor="folder-name">Folder Name</Label>
|
||||||
|
<Input
|
||||||
|
id="folder-name"
|
||||||
|
value={newFolderName}
|
||||||
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
|
placeholder="My Folder"
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => setIsCreateFolderOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateFolder}>Create</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal file
80
apps/remix/app/components/dialogs/folder-delete-dialog.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type FolderDeleteDialogProps = {
|
||||||
|
folder: TFolderWithSubfolders | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDeleteDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { mutateAsync: deleteFolder } = trpc.folder.deleteFolder.useMutation();
|
||||||
|
|
||||||
|
const handleDeleteFolder = async () => {
|
||||||
|
if (!folder) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteFolder({
|
||||||
|
id: folder.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Folder deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting folder:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to delete folder',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Folder</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this folder?
|
||||||
|
{folder && folder._count.documents > 0 && (
|
||||||
|
<span className="text-destructive mt-2 block">
|
||||||
|
Warning: This folder contains {folder._count.documents} document(s). Deleting it
|
||||||
|
will move all documents to the root.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{folder && folder._count.subfolders > 0 && (
|
||||||
|
<span className="text-destructive mt-2 block">
|
||||||
|
Warning: This folder contains {folder._count.subfolders} subfolder(s). Deleting it
|
||||||
|
will delete all subfolders and their contents.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteFolder}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
97
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal file
97
apps/remix/app/components/dialogs/folder-move-dialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { FolderIcon, HomeIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type FolderMoveDialogProps = {
|
||||||
|
foldersData: TFolderWithSubfolders[] | undefined;
|
||||||
|
folder: TFolderWithSubfolders | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const FolderMoveDialog = ({
|
||||||
|
foldersData,
|
||||||
|
folder,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: FolderMoveDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
||||||
|
|
||||||
|
const handleMoveFolder = async (targetFolderId: number | null) => {
|
||||||
|
if (!folder) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await moveFolder({
|
||||||
|
id: folder.id,
|
||||||
|
parentId: targetFolderId,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Folder moved successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error moving folder:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to move folder',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Move Folder</DialogTitle>
|
||||||
|
<DialogDescription>Select a destination for this folder.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={async () => await handleMoveFolder(null)}
|
||||||
|
>
|
||||||
|
<HomeIcon className="mr-2 h-4 w-4" />
|
||||||
|
Root
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{foldersData &&
|
||||||
|
foldersData
|
||||||
|
.filter((f) => f.id !== folder?.id)
|
||||||
|
.map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f.id}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={async () => await handleMoveFolder(f.id)}
|
||||||
|
>
|
||||||
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
|
{f.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
121
apps/remix/app/components/dialogs/folder-rename-dialog.tsx
Normal file
121
apps/remix/app/components/dialogs/folder-rename-dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type FolderRenameDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
folder?: TFolderWithSubfolders | null;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
export const FolderRenameDialog = ({
|
||||||
|
trigger,
|
||||||
|
folder,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
...props
|
||||||
|
}: FolderRenameDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isRenameFolderOpen, setIsRenameFolderOpen] = useState(isOpen || false);
|
||||||
|
const [renameFolderName, setRenameFolderName] = useState('');
|
||||||
|
const [folderToRename, setFolderToRename] = useState<TFolderWithSubfolders | null>(
|
||||||
|
folder || null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen !== undefined) {
|
||||||
|
setIsRenameFolderOpen(isOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folder && isOpen) {
|
||||||
|
setFolderToRename(folder);
|
||||||
|
setRenameFolderName(folder.name);
|
||||||
|
}
|
||||||
|
}, [isOpen, folder]);
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
setIsRenameFolderOpen(open);
|
||||||
|
if (onOpenChange) {
|
||||||
|
onOpenChange(open);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameFolder = async () => {
|
||||||
|
if (!folderToRename) return;
|
||||||
|
|
||||||
|
if (!renameFolderName.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Folder name is required',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateFolder({
|
||||||
|
id: folderToRename.id,
|
||||||
|
name: renameFolderName,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Folder renamed successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
setFolderToRename(null);
|
||||||
|
setRenameFolderName('');
|
||||||
|
handleOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error renaming folder:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to rename folder',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog {...props} open={isRenameFolderOpen} onOpenChange={handleOpenChange}>
|
||||||
|
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rename Folder</DialogTitle>
|
||||||
|
<DialogDescription>Enter a new name for your folder.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Label htmlFor="rename-folder-name">Folder Name</Label>
|
||||||
|
<Input
|
||||||
|
id="rename-folder-name"
|
||||||
|
value={renameFolderName}
|
||||||
|
onChange={(e) => setRenameFolderName(e.target.value)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleRenameFolder}>Rename</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,9 +16,9 @@ export type EmbedAuthenticationRequiredProps = {
|
|||||||
export const EmbedAuthenticationRequired = ({
|
export const EmbedAuthenticationRequired = ({
|
||||||
email,
|
email,
|
||||||
returnTo,
|
returnTo,
|
||||||
isGoogleSSOEnabled,
|
// isGoogleSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
// isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
// oidcProviderLabel,
|
||||||
}: EmbedAuthenticationRequiredProps) => {
|
}: EmbedAuthenticationRequiredProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] w-full items-center justify-center">
|
<div className="flex min-h-[100dvh] w-full items-center justify-center">
|
||||||
@@ -35,9 +35,10 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<SignInForm
|
<SignInForm
|
||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
// Embed currently not supported.
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
oidcProviderLabel={oidcProviderLabel}
|
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
|
// oidcProviderLabel={oidcProviderLabel}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
initialEmail={email}
|
initialEmail={email}
|
||||||
returnTo={returnTo}
|
returnTo={returnTo}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ 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,7 +29,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { 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 { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
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';
|
||||||
|
|
||||||
@@ -92,7 +96,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) => !field.inserted),
|
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
||||||
localFields.filter((field) => field.inserted),
|
localFields.filter((field) => field.inserted),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -110,7 +114,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,
|
||||||
});
|
});
|
||||||
@@ -121,8 +125,10 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
created: new Date(),
|
created: new Date(),
|
||||||
recipientId: 1,
|
recipientId: 1,
|
||||||
fieldId: 1,
|
fieldId: 1,
|
||||||
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
signatureImageAsBase64:
|
||||||
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
payload.value && payload.value.startsWith('data:') ? payload.value : null,
|
||||||
|
typedSignature:
|
||||||
|
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +186,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onNextFieldClick = () => {
|
const onNextFieldClick = () => {
|
||||||
validateFieldsInserted(localFields);
|
validateFieldsInserted(pendingFields);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -192,7 +198,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = validateFieldsInserted(localFields);
|
const valid = validateFieldsInserted(pendingFields);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -205,12 +211,6 @@ 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,
|
||||||
@@ -221,13 +221,11 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
directRecipientName: fullName,
|
directRecipientName: fullName,
|
||||||
directRecipientEmail: email,
|
directRecipientEmail: email,
|
||||||
templateUpdatedAt: updatedAt,
|
templateUpdatedAt: updatedAt,
|
||||||
signedFieldValues: localFields.map((field) => {
|
signedFieldValues: localFields
|
||||||
if (!field.signedValue) {
|
.filter((field) => {
|
||||||
throw new Error('Invalid configuration');
|
return field.signedValue && (isRequiredField(field) || field.inserted);
|
||||||
}
|
})
|
||||||
|
.map((field) => field.signedValue!),
|
||||||
return field.signedValue;
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (window.parent) {
|
if (window.parent) {
|
||||||
@@ -338,7 +336,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
documentData={documentData}
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
@@ -347,7 +345,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 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 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"
|
||||||
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">
|
||||||
@@ -415,40 +413,42 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{hasSignatureField && (
|
||||||
<Label htmlFor="Signature">
|
<div>
|
||||||
<Trans>Signature</Trans>
|
<Label htmlFor="Signature">
|
||||||
</Label>
|
<Trans>Signature</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
disabled={isThrottled || isSubmitting}
|
disabled={isThrottled || isSubmitting}
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
}}
|
}}
|
||||||
onValidityChange={(isValid) => {
|
onValidityChange={(isValid) => {
|
||||||
setSignatureValid(isValid);
|
setSignatureValid(isValid);
|
||||||
}}
|
}}
|
||||||
allowTypedSignature={Boolean(
|
allowTypedSignature={Boolean(
|
||||||
metadata &&
|
metadata &&
|
||||||
'typedSignatureEnabled' in metadata &&
|
'typedSignatureEnabled' in metadata &&
|
||||||
metadata.typedSignatureEnabled,
|
metadata.typedSignatureEnabled,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
{hasSignatureField && !signatureValid && (
|
||||||
<div className="text-destructive mt-2 text-sm">
|
<div className="text-destructive mt-2 text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Signature is too small. Please provide a more complete signature.
|
Signature is too small. Please provide a more complete signature.
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useId, useLayoutEffect, useState } from 'react';
|
import { useEffect, useId, useLayoutEffect, useMemo, 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,6 +15,7 @@ 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 { 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';
|
||||||
@@ -24,7 +25,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { 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 { LazyPDFViewer } from '@documenso/ui/primitives/lazy-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 { 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';
|
||||||
@@ -101,19 +102,26 @@ 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((field) => field.recipientId === recipient.id && !field.inserted),
|
fields.filter(
|
||||||
|
(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(fields);
|
validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
@@ -125,7 +133,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = validateFieldsInserted(fields);
|
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
setShowPendingFieldTooltip(true);
|
setShowPendingFieldTooltip(true);
|
||||||
@@ -278,7 +286,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="embed--DocumentViewer flex-1">
|
<div className="embed--DocumentViewer flex-1">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
documentData={documentData}
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
@@ -287,7 +295,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 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 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"
|
||||||
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">
|
||||||
@@ -418,40 +426,42 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{hasSignatureField && (
|
||||||
<Label htmlFor="Signature">
|
<div>
|
||||||
<Trans>Signature</Trans>
|
<Label htmlFor="Signature">
|
||||||
</Label>
|
<Trans>Signature</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
disabled={isThrottled || isSubmitting}
|
disabled={isThrottled || isSubmitting}
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
}}
|
}}
|
||||||
onValidityChange={(isValid) => {
|
onValidityChange={(isValid) => {
|
||||||
setSignatureValid(isValid);
|
setSignatureValid(isValid);
|
||||||
}}
|
}}
|
||||||
allowTypedSignature={Boolean(
|
allowTypedSignature={Boolean(
|
||||||
metadata &&
|
metadata &&
|
||||||
'typedSignatureEnabled' in metadata &&
|
'typedSignatureEnabled' in metadata &&
|
||||||
metadata.typedSignatureEnabled,
|
metadata.typedSignatureEnabled,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
{hasSignatureField && !signatureValid && (
|
||||||
<div className="text-destructive mt-2 text-sm">
|
<div className="text-destructive mt-2 text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Signature is too small. Please provide a more complete signature.
|
Signature is too small. Please provide a more complete signature.
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { revalidate } = useRevalidator();
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
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 revalidate();
|
await refreshSession();
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Unable to disable two-factor authentication`),
|
title: _(msg`Unable to disable two-factor authentication`),
|
||||||
|
|||||||
@@ -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 { revalidate } = useRevalidator();
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
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,6 +74,7 @@ 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) {
|
||||||
@@ -92,6 +93,7 @@ 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?.();
|
||||||
@@ -139,7 +141,6 @@ 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
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const AppNavDesktop = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-muted-foreground flex w-96 items-center justify-between rounded-lg"
|
className="text-muted-foreground flex w-full max-w-96 items-center justify-between rounded-lg"
|
||||||
onClick={() => setIsCommandMenuOpen(true)}
|
onClick={() => setIsCommandMenuOpen(true)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export const DirectTemplatePageView = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
key={template.id}
|
key={template.id}
|
||||||
documentData={template.templateDocumentData}
|
documentData={template.templateDocumentData}
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, 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';
|
||||||
@@ -91,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,
|
||||||
};
|
};
|
||||||
@@ -102,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.startsWith('data:') ? null : value.value,
|
typedSignature: value.value && !value.value.startsWith('data:') ? value.value : null,
|
||||||
} satisfies Signature;
|
} satisfies Signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +170,55 @@ 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} />
|
||||||
|
|||||||
@@ -45,26 +45,24 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
|
|
||||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta);
|
const parsedFieldMeta = ZCheckboxFieldMeta.parse(
|
||||||
|
field.fieldMeta ?? {
|
||||||
|
type: 'checkbox',
|
||||||
|
values: [{ id: 1, checked: false, value: '' }],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const values = parsedFieldMeta.values?.map((item) => ({
|
const values = parsedFieldMeta.values?.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const parsedCheckedValues = useMemo(
|
|
||||||
() => fromCheckboxValue(field.customText),
|
|
||||||
[field.customText],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [checkedValues, setCheckedValues] = useState(
|
const [checkedValues, setCheckedValues] = useState(
|
||||||
field.inserted && parsedCheckedValues.length > 0
|
values
|
||||||
? parsedCheckedValues
|
?.map((item) =>
|
||||||
: values
|
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
|
||||||
?.map((item) =>
|
)
|
||||||
item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '',
|
.filter(Boolean) || [],
|
||||||
)
|
|
||||||
.filter(Boolean) || [],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const isReadOnly = parsedFieldMeta.readOnly;
|
const isReadOnly = parsedFieldMeta.readOnly;
|
||||||
@@ -99,6 +97,10 @@ 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,
|
||||||
@@ -179,29 +181,47 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
let updatedValues: string[] = [];
|
let updatedValues: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const itemValue = item.value.length > 0 ? item.value : `empty-value-${item.id}`;
|
const isChecked = checkedValues.includes(
|
||||||
const isChecked = checkedValues.includes(itemValue);
|
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!isChecked) {
|
if (!isChecked) {
|
||||||
updatedValues = [...checkedValues, itemValue];
|
updatedValues = [
|
||||||
|
...checkedValues,
|
||||||
|
item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
updatedValues = checkedValues.filter((v) => v !== itemValue);
|
updatedValues = checkedValues.filter(
|
||||||
|
(v) => v !== item.value && v !== `empty-value-${item.id}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCheckedValues(updatedValues);
|
setCheckedValues(updatedValues);
|
||||||
|
|
||||||
await removeSignedFieldWithToken({
|
const removePayload: TRemovedSignedFieldWithTokenMutationSchema = {
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
if (updatedValues.length > 0) {
|
if (onUnsignField) {
|
||||||
await signFieldWithToken({
|
await onUnsignField(removePayload);
|
||||||
|
} 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);
|
||||||
@@ -216,12 +236,6 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (field.inserted && parsedCheckedValues.length > 0) {
|
|
||||||
setCheckedValues(parsedCheckedValues);
|
|
||||||
}
|
|
||||||
}, [field.inserted, parsedCheckedValues]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldAutoSignField) {
|
if (shouldAutoSignField) {
|
||||||
void executeActionAuthProcedure({
|
void executeActionAuthProcedure({
|
||||||
@@ -231,6 +245,11 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
}
|
}
|
||||||
}, [checkedValues, isLengthConditionMet, field.inserted]);
|
}, [checkedValues, isLengthConditionMet, field.inserted]);
|
||||||
|
|
||||||
|
const parsedCheckedValues = useMemo(
|
||||||
|
() => fromCheckboxValue(field.customText),
|
||||||
|
[field.customText],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentSigningFieldContainer
|
<DocumentSigningFieldContainer
|
||||||
field={field}
|
field={field}
|
||||||
@@ -254,17 +273,16 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
<div className="z-50 flex flex-col gap-y-2">
|
<div className="z-50 flex flex-col gap-y-2">
|
||||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||||
const itemValue = item.value || `empty-value-${item.id}`;
|
const itemValue = item.value || `empty-value-${item.id}`;
|
||||||
const checkboxId = `checkbox-field-${field.id}-${index}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-x-1.5">
|
<div key={index} className="flex items-center gap-x-1.5">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
id={checkboxId}
|
id={`checkbox-${index}`}
|
||||||
checked={checkedValues.includes(itemValue)}
|
checked={checkedValues.includes(itemValue)}
|
||||||
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
|
onCheckedChange={() => handleCheckboxChange(item.value, item.id)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={checkboxId}>
|
<Label htmlFor={`checkbox-${index}`}>
|
||||||
{item.value.includes('empty-value-') ? '' : item.value}
|
{item.value.includes('empty-value-') ? '' : item.value}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,21 +293,20 @@ export const DocumentSigningCheckboxField = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<div className="flex flex-col gap-y-2">
|
<div className="flex flex-col gap-y-1">
|
||||||
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
{values?.map((item: { id: number; value: string; checked: boolean }, index: number) => {
|
||||||
const itemValue = item.value || `empty-value-${item.id}`;
|
const itemValue = item.value || `empty-value-${item.id}`;
|
||||||
const checkboxId = `checkbox-field-${field.id}-${index}-inserted`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-x-1.5">
|
<div key={index} className="flex items-center gap-x-1.5">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="h-4 w-4"
|
className="h-3 w-3"
|
||||||
id={checkboxId}
|
id={`checkbox-${index}`}
|
||||||
checked={checkedValues.includes(itemValue)}
|
checked={parsedCheckedValues.includes(itemValue)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onCheckedChange={() => void handleCheckboxOptionClick(item)}
|
onCheckedChange={() => void handleCheckboxOptionClick(item)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={checkboxId}>
|
<Label htmlFor={`checkbox-${index}`} className="text-xs">
|
||||||
{item.value.includes('empty-value-') ? '' : item.value}
|
{item.value.includes('empty-value-') ? '' : item.value}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
|
|||||||
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 { match } from 'ts-pattern';
|
||||||
|
|
||||||
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';
|
||||||
@@ -58,62 +59,88 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{isComplete ? <Trans>Complete</Trans> : <Trans>Next field</Trans>}
|
{match({ isComplete, role })
|
||||||
|
.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>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div className="text-foreground text-xl font-semibold">
|
<div className="text-foreground text-xl font-semibold">
|
||||||
{role === RecipientRole.VIEWER && <Trans>Complete Viewing</Trans>}
|
{match(role)
|
||||||
{role === RecipientRole.SIGNER && <Trans>Complete Signing</Trans>}
|
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
||||||
{role === RecipientRole.APPROVER && <Trans>Complete Approval</Trans>}
|
.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>
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
<div className="text-muted-foreground max-w-[50ch]">
|
||||||
{role === RecipientRole.VIEWER && (
|
{match(role)
|
||||||
<span>
|
.with(RecipientRole.VIEWER, () => (
|
||||||
<Trans>
|
<span>
|
||||||
<span className="inline-flex flex-wrap">
|
<Trans>
|
||||||
You are about to complete viewing "
|
<span className="inline-flex flex-wrap">
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
You are about to complete viewing "
|
||||||
{documentTitle}
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
|
{documentTitle}
|
||||||
|
</span>
|
||||||
|
".
|
||||||
</span>
|
</span>
|
||||||
".
|
<br /> Are you sure?
|
||||||
</span>
|
</Trans>
|
||||||
<br /> Are you sure?
|
</span>
|
||||||
</Trans>
|
))
|
||||||
</span>
|
.with(RecipientRole.SIGNER, () => (
|
||||||
)}
|
<span>
|
||||||
{role === RecipientRole.SIGNER && (
|
<Trans>
|
||||||
<span>
|
<span className="inline-flex flex-wrap">
|
||||||
<Trans>
|
You are about to complete signing "
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
You are about to complete signing "
|
{documentTitle}
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
</span>
|
||||||
{documentTitle}
|
".
|
||||||
</span>
|
</span>
|
||||||
".
|
<br /> Are you sure?
|
||||||
</span>
|
</Trans>
|
||||||
<br /> Are you sure?
|
</span>
|
||||||
</Trans>
|
))
|
||||||
</span>
|
.with(RecipientRole.APPROVER, () => (
|
||||||
)}
|
<span>
|
||||||
{role === RecipientRole.APPROVER && (
|
<Trans>
|
||||||
<span>
|
<span className="inline-flex flex-wrap">
|
||||||
<Trans>
|
You are about to complete approving{' '}
|
||||||
<span className="inline-flex flex-wrap">
|
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
||||||
You are about to complete approving{' '}
|
"{documentTitle}"
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
</span>
|
||||||
"{documentTitle}"
|
.
|
||||||
</span>
|
</span>
|
||||||
.
|
<br /> Are you sure?
|
||||||
</span>
|
</Trans>
|
||||||
<br /> Are you sure?
|
</span>
|
||||||
</Trans>
|
))
|
||||||
</span>
|
.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>
|
</div>
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
<DocumentSigningDisclosure className="mt-4" />
|
||||||
@@ -138,9 +165,13 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={onSignatureComplete}
|
onClick={onSignatureComplete}
|
||||||
>
|
>
|
||||||
{role === RecipientRole.VIEWER && <Trans>Mark as Viewed</Trans>}
|
{match(role)
|
||||||
{role === RecipientRole.SIGNER && <Trans>Sign</Trans>}
|
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
||||||
{role === RecipientRole.APPROVER && <Trans>Approve</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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -181,6 +181,23 @@ 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>
|
||||||
|
|||||||
@@ -313,7 +313,11 @@ export const DocumentSigningForm = ({
|
|||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
<Trans>Please review the document before signing.</Trans>
|
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
||||||
|
<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" />
|
||||||
@@ -337,38 +341,40 @@ export const DocumentSigningForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{hasSignatureField && (
|
||||||
<Label htmlFor="Signature">
|
<div>
|
||||||
<Trans>Signature</Trans>
|
<Label htmlFor="Signature">
|
||||||
</Label>
|
<Trans>Signature</Trans>
|
||||||
|
</Label>
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<Card className="mt-2" gradient degrees={-120}>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onValidityChange={(isValid) => {
|
onValidityChange={(isValid) => {
|
||||||
setSignatureValid(isValid);
|
setSignatureValid(isValid);
|
||||||
}}
|
}}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (signatureValid) {
|
if (signatureValid) {
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
allowTypedSignature={document.documentMeta?.typedSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{hasSignatureField && !signatureValid && (
|
{!signatureValid && (
|
||||||
<div className="text-destructive mt-2 text-sm">
|
<div className="text-destructive mt-2 text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Signature is too small. Please provide a more complete signature.
|
Signature is too small. Please provide a more complete signature.
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/fie
|
|||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
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 { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||||
@@ -140,12 +140,7 @@ export const DocumentSigningPageView = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||||
key={documentData.id}
|
|
||||||
documentData={documentData}
|
|
||||||
document={document}
|
|
||||||
password={documentMeta?.password}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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 { DocumentStatus } from '@prisma/client';
|
import type { 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';
|
||||||
@@ -76,7 +77,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={documentStatus !== DocumentStatus.COMPLETED}
|
disabled={!isDocumentCompleted(documentStatus)}
|
||||||
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" />}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad
|
|||||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -132,9 +132,6 @@ export const DocumentEditForm = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: setPasswordForDocument } =
|
|
||||||
trpc.document.setPasswordForDocument.useMutation();
|
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
settings: {
|
settings: {
|
||||||
title: msg`General`,
|
title: msg`General`,
|
||||||
@@ -315,13 +312,6 @@ export const DocumentEditForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPasswordSubmit = async (password: string) => {
|
|
||||||
await setPasswordForDocument({
|
|
||||||
documentId: document.id,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -340,12 +330,10 @@ export const DocumentEditForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
key={document.documentData.id}
|
key={document.documentData.id}
|
||||||
documentData={document.documentData}
|
documentData={document.documentData}
|
||||||
document={document}
|
document={document}
|
||||||
password={document.documentMeta?.password}
|
|
||||||
onPasswordSubmit={onPasswordSubmit}
|
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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';
|
||||||
@@ -32,7 +33,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 = document.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(document);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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';
|
||||||
@@ -63,7 +64,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 = document.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(document);
|
||||||
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);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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';
|
||||||
@@ -48,7 +49,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
<Trans>Recipients</Trans>
|
<Trans>Recipients</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
{!isDocumentCompleted(document.status) && (
|
||||||
<Link
|
<Link
|
||||||
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||||
title={_(msg`Modify recipients`)}
|
title={_(msg`Modify recipients`)}
|
||||||
|
|||||||
@@ -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 } from 'lucide-react';
|
import { CheckCircle2, Clock, File, XCircle } 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,6 +36,12 @@ 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`,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const GenericErrorLayout = ({
|
|||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { subHeading, heading, message } =
|
const { subHeading, heading, message } =
|
||||||
errorCodeMap[errorCode || 404] ?? defaultErrorCodeMap[500];
|
errorCodeMap[errorCode || 500] ?? 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">
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ 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();
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||||
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
|
||||||
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
|
||||||
@@ -236,7 +236,7 @@ export const TemplateEditForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<PDFViewer
|
||||||
key={templateDocumentData.id}
|
key={templateDocumentData.id}
|
||||||
documentData={templateDocumentData}
|
documentData={templateDocumentData}
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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';
|
||||||
@@ -37,7 +38,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 = row.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
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;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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';
|
||||||
@@ -47,9 +48,13 @@ export type DocumentsTableActionDropdownProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
|
onMoveDocument?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
|
export const DocumentsTableActionDropdown = ({
|
||||||
|
row,
|
||||||
|
onMoveDocument,
|
||||||
|
}: DocumentsTableActionDropdownProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
@@ -66,7 +71,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 = row.status === DocumentStatus.COMPLETED;
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
// 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);
|
||||||
@@ -164,6 +169,13 @@ export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdo
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onMoveDocument && (
|
||||||
|
<DropdownMenuItem onClick={onMoveDocument} onSelect={(e) => e.preventDefault()}>
|
||||||
|
<MoveRight className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Move to Folder</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* No point displaying this if there's no functionality. */}
|
{/* No point displaying this if there's no functionality. */}
|
||||||
{/* <DropdownMenuItem disabled>
|
{/* <DropdownMenuItem disabled>
|
||||||
<XCircle className="mr-2 h-4 w-4" />
|
<XCircle className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -29,11 +29,17 @@ export type DocumentsTableProps = {
|
|||||||
data?: TFindDocumentsResponse;
|
data?: TFindDocumentsResponse;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isLoadingError?: boolean;
|
isLoadingError?: boolean;
|
||||||
|
onMoveDocument?: (documentId: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
||||||
|
|
||||||
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
|
export const DocumentsTable = ({
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isLoadingError,
|
||||||
|
onMoveDocument,
|
||||||
|
}: DocumentsTableProps) => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
@@ -77,15 +83,18 @@ export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTab
|
|||||||
{
|
{
|
||||||
header: _(msg`Actions`),
|
header: _(msg`Actions`),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
||||||
<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}
|
||||||
|
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
||||||
}, [team]);
|
}, [team, onMoveDocument, _, i18n]);
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function PosthogInit() {
|
|||||||
if (postHogConfig) {
|
if (postHogConfig) {
|
||||||
posthog.init(postHogConfig.key, {
|
posthog.init(postHogConfig.key, {
|
||||||
api_host: postHogConfig.host,
|
api_host: postHogConfig.host,
|
||||||
|
capture_exceptions: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ 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';
|
||||||
@@ -159,8 +158,6 @@ 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)}`,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type { Route } from './+types/_layout';
|
|||||||
*/
|
*/
|
||||||
export const shouldRevalidate = () => false;
|
export const shouldRevalidate = () => false;
|
||||||
|
|
||||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const requestHeaders = Object.fromEntries(request.headers.entries());
|
const requestHeaders = Object.fromEntries(request.headers.entries());
|
||||||
|
|
||||||
const session = await getOptionalSession(request);
|
const session = await getOptionalSession(request);
|
||||||
@@ -40,7 +40,7 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
banner,
|
banner,
|
||||||
limits,
|
limits,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function Layout({ loaderData }: Route.ComponentProps) {
|
export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||||
const { user, teams } = useSession();
|
const { user, teams } = useSession();
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ 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.signingStatus !== SigningStatus.SIGNED,
|
(recipient) =>
|
||||||
|
recipient.signingStatus !== SigningStatus.SIGNED &&
|
||||||
|
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||||
)}
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: document.id })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
|
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
|
||||||
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
|
||||||
@@ -196,7 +196,7 @@ export default function DocumentPage() {
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer document={document} key={documentData.id} documentData={documentData} />
|
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -220,6 +220,9 @@ 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>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Plural, Trans } from '@lingui/react/macro';
|
import { Plural, Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus as InternalDocumentStatus, TeamMemberRole } from '@prisma/client';
|
import { 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,6 +9,7 @@ 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';
|
||||||
@@ -71,7 +72,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw redirect(documentRootPath);
|
throw redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === InternalDocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
throw redirect(`${documentRootPath}/${documentId}`);
|
throw redirect(`${documentRootPath}/${documentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
<Trans>Document</Trans>
|
<Trans>Document</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col justify-between truncate sm:flex-row">
|
<div className="flex flex-col">
|
||||||
<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,7 +137,8 @@ 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
|
||||||
@@ -145,16 +146,15 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
|||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
||||||
|
<DocumentCertificateDownloadButton
|
||||||
|
className="mr-2"
|
||||||
|
documentId={document.id}
|
||||||
|
documentStatus={document.status}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
|
<DocumentAuditLogDownloadButton documentId={document.id} />
|
||||||
<DocumentCertificateDownloadButton
|
</div>
|
||||||
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">{info.value}</p>
|
<p className="text-muted-foreground truncate">{info.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ 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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscriptio
|
|||||||
import { BillingPlans } from '~/components/general/billing-plans';
|
import { BillingPlans } from '~/components/general/billing-plans';
|
||||||
import { BillingPortalButton } from '~/components/general/billing-portal-button';
|
import { BillingPortalButton } from '~/components/general/billing-portal-button';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
import type { Route } from './+types/billing';
|
import type { Route } from './+types/billing';
|
||||||
|
|
||||||
@@ -62,17 +63,17 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
const isMissingOrInactiveOrFreePlan =
|
const isMissingOrInactiveOrFreePlan =
|
||||||
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
|
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
|
||||||
|
|
||||||
return {
|
return superLoaderJson({
|
||||||
prices,
|
prices,
|
||||||
subscription,
|
subscription,
|
||||||
subscriptionProductName: subscriptionProduct?.name,
|
subscriptionProductName: subscriptionProduct?.name,
|
||||||
isMissingOrInactiveOrFreePlan,
|
isMissingOrInactiveOrFreePlan,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamsSettingBillingPage({ loaderData }: Route.ComponentProps) {
|
export default function TeamsSettingBillingPage() {
|
||||||
const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } =
|
const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } =
|
||||||
loaderData;
|
useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { SettingsHeader } from '~/components/general/settings-header';
|
import { SettingsHeader } from '~/components/general/settings-header';
|
||||||
import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button';
|
import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button';
|
||||||
import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table';
|
import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
import type { Route } from './+types/settings.billing';
|
import type { Route } from './+types/settings.billing';
|
||||||
|
|
||||||
@@ -31,16 +32,16 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
|||||||
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return superLoaderJson({
|
||||||
team,
|
team,
|
||||||
teamSubscription,
|
teamSubscription,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamsSettingBillingPage({ loaderData }: Route.ComponentProps) {
|
export default function TeamsSettingBillingPage() {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const { team, teamSubscription } = loaderData;
|
const { team, teamSubscription } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
|
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
|
|||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
|
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
|
||||||
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
|
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
|
||||||
@@ -144,11 +144,7 @@ export default function TemplatePage() {
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<PDFViewer document={template} key={template.id} documentData={templateDocumentData} />
|
||||||
document={template}
|
|
||||||
key={template.id}
|
|
||||||
documentData={templateDocumentData}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,47 @@
|
|||||||
import { redirect } from 'react-router';
|
import { redirect } from 'react-router';
|
||||||
|
|
||||||
|
import { extractCookieFromHeaders } from '@documenso/auth/server/lib/utils/cookies';
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { ZTeamUrlSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
|
|
||||||
import type { Route } from './+types/_index';
|
import type { Route } from './+types/_index';
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const { isAuthenticated } = await getOptionalSession(request);
|
const session = await getOptionalSession(request);
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (session.isAuthenticated) {
|
||||||
throw redirect('/documents');
|
const teamUrlCookie = extractCookieFromHeaders('preferred-team-url', request.headers);
|
||||||
|
|
||||||
|
const referrer = request.headers.get('referer');
|
||||||
|
let isReferrerFromTeamUrl = false;
|
||||||
|
|
||||||
|
if (referrer) {
|
||||||
|
const referrerUrl = new URL(referrer);
|
||||||
|
|
||||||
|
if (referrerUrl.pathname.startsWith('/t/')) {
|
||||||
|
isReferrerFromTeamUrl = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferredTeamUrl =
|
||||||
|
teamUrlCookie && ZTeamUrlSchema.safeParse(teamUrlCookie).success ? teamUrlCookie : undefined;
|
||||||
|
|
||||||
|
// Early return for no preferred team.
|
||||||
|
if (!preferredTeamUrl || isReferrerFromTeamUrl) {
|
||||||
|
throw redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = await getTeams({ userId: session.user.id });
|
||||||
|
|
||||||
|
const currentTeam = teams.find((team) => team.url === preferredTeamUrl);
|
||||||
|
|
||||||
|
if (!currentTeam) {
|
||||||
|
throw redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw redirect(formatDocumentsPath(currentTeam.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
throw redirect('/signin');
|
throw redirect('/signin');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType, SigningStatus } 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';
|
||||||
@@ -159,6 +159,13 @@ 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,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,25 +289,42 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm print:text-xs">
|
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
|
||||||
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
|
<p className="text-muted-foreground text-sm print:text-xs">
|
||||||
<span className="inline-block">
|
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
|
||||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
<span className="inline-block">
|
||||||
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
|
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
|
||||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
|
||||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||||
: _(msg`Unknown`)}
|
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||||
</span>
|
: _(msg`Unknown`)}
|
||||||
</p>
|
</span>
|
||||||
|
</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
|
||||||
isOwner(recipient.email)
|
? recipient.rejectionReason
|
||||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
: _(
|
||||||
: FRIENDLY_SIGNING_REASONS[recipient.role],
|
isOwner(recipient.email)
|
||||||
)}
|
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||||
|
: FRIENDLY_SIGNING_REASONS[recipient.role],
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 } from 'react-router';
|
import { Link, Outlet, isRouteErrorResponse } 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,6 +16,8 @@ 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');
|
||||||
}
|
}
|
||||||
@@ -96,7 +98,9 @@ export default function PublicProfileLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
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`,
|
||||||
@@ -107,6 +111,7 @@ export function ErrorBoundary() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericErrorLayout
|
<GenericErrorLayout
|
||||||
|
errorCode={errorCode}
|
||||||
errorCodeMap={errorCodeMap}
|
errorCodeMap={errorCodeMap}
|
||||||
secondaryButton={null}
|
secondaryButton={null}
|
||||||
primaryButton={
|
primaryButton={
|
||||||
|
|||||||
@@ -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 } from 'react-router';
|
import { Link, Outlet, isRouteErrorResponse } 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,6 +8,8 @@ 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.
|
||||||
@@ -30,9 +32,12 @@ export default function RecipientLayout() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
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">
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export default function SigningPage() {
|
|||||||
recipientWithFields,
|
recipientWithFields,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
if (document.deletedAt) {
|
if (document.deletedAt || document.status === DocumentStatus.REJECTED) {
|
||||||
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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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';
|
||||||
@@ -205,12 +206,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} />
|
||||||
|
|
||||||
{document.status === DocumentStatus.COMPLETED ? (
|
{isDocumentCompleted(document.status) ? (
|
||||||
<DocumentDownloadButton
|
<DocumentDownloadButton
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
fileName={document.title}
|
fileName={document.title}
|
||||||
documentData={document.documentData}
|
documentData={document.documentData}
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
disabled={!isDocumentCompleted(document.status)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DocumentDialog
|
<DocumentDialog
|
||||||
@@ -268,7 +269,7 @@ export const PollUntilDocumentCompleted = ({ document }: PollUntilDocumentComple
|
|||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,18 +19,30 @@ 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 response = await fetch(newUrl, {
|
const fetchOptions: RequestInit = {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
headers,
|
headers,
|
||||||
body: request.body,
|
redirect: 'follow',
|
||||||
// @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: response.headers,
|
headers: responseHeaders,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,9 @@ export default function EmbedDirectTemplatePage() {
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
metadata={template.templateMeta}
|
metadata={template.templateMeta}
|
||||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
hidePoweredBy={
|
||||||
|
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||||
|
}
|
||||||
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
allowWhiteLabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
||||||
/>
|
/>
|
||||||
</DocumentSigningRecipientProvider>
|
</DocumentSigningRecipientProvider>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
import { RecipientRole } from '@prisma/client';
|
||||||
import { data } from 'react-router';
|
import { data } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-re
|
|||||||
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';
|
||||||
@@ -168,8 +169,10 @@ export default function EmbedSignDocumentPage() {
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
metadata={document.documentMeta}
|
metadata={document.documentMeta}
|
||||||
isCompleted={document.status === DocumentStatus.COMPLETED}
|
isCompleted={isDocumentCompleted(document.status)}
|
||||||
hidePoweredBy={isPlatformDocument || isEnterpriseDocument || hidePoweredBy}
|
hidePoweredBy={
|
||||||
|
isCommunityPlan || isPlatformDocument || isEnterpriseDocument || hidePoweredBy
|
||||||
|
}
|
||||||
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
allowWhitelabelling={isCommunityPlan || isPlatformDocument || isEnterpriseDocument}
|
||||||
allRecipients={allRecipients}
|
allRecipients={allRecipients}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
1
apps/remix/app/types/empty-module.ts
Normal file
1
apps/remix/app/types/empty-module.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default {};
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"plausible-tracker": "^0.3.9",
|
"plausible-tracker": "^0.3.9",
|
||||||
"posthog-js": "^1.223.3",
|
"posthog-js": "^1.224.0",
|
||||||
"posthog-node": "^4.7.0",
|
"posthog-node": "^4.8.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-call": "^1.3.0",
|
"react-call": "^1.3.0",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
"@babel/preset-typescript": "^7.26.0",
|
"@babel/preset-typescript": "^7.26.0",
|
||||||
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
|
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
|
||||||
"@lingui/vite-plugin": "^5.2.0",
|
"@lingui/vite-plugin": "^5.2.0",
|
||||||
"@react-router/dev": "^7.1.1",
|
"@react-router/dev": "^7.1.5",
|
||||||
"@react-router/remix-routes-option-adapter": "^7.1.5",
|
"@react-router/remix-routes-option-adapter": "^7.1.5",
|
||||||
"@rollup/plugin-babel": "^6.0.4",
|
"@rollup/plugin-babel": "^6.0.4",
|
||||||
"@rollup/plugin-commonjs": "^28.0.2",
|
"@rollup/plugin-commonjs": "^28.0.2",
|
||||||
|
|||||||
75
apps/remix/server/middleware.ts
Normal file
75
apps/remix/server/middleware.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Context, Next } from 'hono';
|
||||||
|
import { deleteCookie, setCookie } from 'hono/cookie';
|
||||||
|
|
||||||
|
import { AppDebugger } from '@documenso/lib/utils/debugger';
|
||||||
|
|
||||||
|
const debug = new AppDebugger('Middleware');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware for initial page loads.
|
||||||
|
*
|
||||||
|
* You won't be able to easily handle sequential page loads because they will be
|
||||||
|
* called under `path.data`
|
||||||
|
*
|
||||||
|
* Example an initial page load would be `/documents` then if the user click templates
|
||||||
|
* the path here would be `/templates.data`.
|
||||||
|
*/
|
||||||
|
export const appMiddleware = async (c: Context, next: Next) => {
|
||||||
|
const { req } = c;
|
||||||
|
const { path } = req;
|
||||||
|
|
||||||
|
// Paths to ignore.
|
||||||
|
if (nonPagePathRegex.test(path)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRE-HANDLER CODE: Place code here to execute BEFORE the route handler runs.
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
// POST-HANDLER CODE: Place code here to execute AFTER the route handler completes.
|
||||||
|
// This is useful for:
|
||||||
|
// - Setting cookies
|
||||||
|
// - Any operations that should happen after all route handlers but before sending the response
|
||||||
|
|
||||||
|
debug.log('Path', path);
|
||||||
|
|
||||||
|
const pathname = path.replace('.data', '');
|
||||||
|
const referrer = c.req.header('referer');
|
||||||
|
const referrerUrl = referrer ? new URL(referrer) : null;
|
||||||
|
const referrerPathname = referrerUrl ? referrerUrl.pathname : null;
|
||||||
|
|
||||||
|
// Whether to reset the preferred team url cookie if the user accesses a non team page from a team page.
|
||||||
|
const resetPreferredTeamUrl =
|
||||||
|
referrerPathname &&
|
||||||
|
referrerPathname.startsWith('/t/') &&
|
||||||
|
(!pathname.startsWith('/t/') || pathname === '/');
|
||||||
|
|
||||||
|
// Set the preferred team url cookie if user accesses a team page.
|
||||||
|
if (pathname.startsWith('/t/')) {
|
||||||
|
debug.log('Setting preferred team url cookie');
|
||||||
|
|
||||||
|
setCookie(c, 'preferred-team-url', pathname.split('/')[2], {
|
||||||
|
sameSite: 'lax',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear preferred team url cookie if user accesses a non team page from a team page.
|
||||||
|
if (resetPreferredTeamUrl || pathname === '/documents') {
|
||||||
|
debug.log('Deleting preferred team url cookie');
|
||||||
|
|
||||||
|
deleteCookie(c, 'preferred-team-url');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// This regex matches any path that:
|
||||||
|
// 1. Starts with /api/, /ingest/, /__manifest/, or /assets/
|
||||||
|
// 2. Starts with /apple- (like /apple-touch-icon.png)
|
||||||
|
// 3. Starts with /favicon (like /favicon.ico)
|
||||||
|
// The ^ ensures matching from the beginning of the string
|
||||||
|
// The | acts as OR operator between different patterns
|
||||||
|
const nonPagePathRegex = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/apple-.*|\/favicon.*)/;
|
||||||
@@ -9,6 +9,7 @@ import { openApiDocument } from '@documenso/trpc/server/open-api';
|
|||||||
|
|
||||||
import { filesRoute } from './api/files';
|
import { filesRoute } from './api/files';
|
||||||
import { type AppContext, appContext } from './context';
|
import { type AppContext, appContext } from './context';
|
||||||
|
import { appMiddleware } from './middleware';
|
||||||
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
||||||
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
|
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
|
||||||
|
|
||||||
@@ -26,6 +27,11 @@ const app = new Hono<HonoEnv>();
|
|||||||
app.use(contextStorage());
|
app.use(contextStorage());
|
||||||
app.use(appContext);
|
app.use(appContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RR7 app middleware.
|
||||||
|
*/
|
||||||
|
app.use('*', appMiddleware);
|
||||||
|
|
||||||
// Auth server.
|
// Auth server.
|
||||||
app.route('/api/auth', auth);
|
app.route('/api/auth', auth);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default defineConfig({
|
|||||||
https: 'node:https',
|
https: 'node:https',
|
||||||
'.prisma/client/default': '../../node_modules/.prisma/client/default.js',
|
'.prisma/client/default': '../../node_modules/.prisma/client/default.js',
|
||||||
'.prisma/client/index-browser': '../../node_modules/.prisma/client/index-browser.js',
|
'.prisma/client/index-browser': '../../node_modules/.prisma/client/index-browser.js',
|
||||||
|
canvas: './app/types/empty-module.ts',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { defineConfig } from '@lingui/cli';
|
||||||
import type { LinguiConfig } from '@lingui/conf';
|
import type { LinguiConfig } from '@lingui/conf';
|
||||||
|
import { formatter } from '@lingui/format-po';
|
||||||
|
|
||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ const config: LinguiConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
compileNamespace: 'es',
|
compileNamespace: 'es',
|
||||||
|
format: formatter({ lineNumbers: false }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -131,8 +131,8 @@
|
|||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"plausible-tracker": "^0.3.9",
|
"plausible-tracker": "^0.3.9",
|
||||||
"posthog-js": "^1.223.3",
|
"posthog-js": "^1.224.0",
|
||||||
"posthog-node": "^4.7.0",
|
"posthog-node": "^4.8.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-call": "^1.3.0",
|
"react-call": "^1.3.0",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
"@babel/preset-typescript": "^7.26.0",
|
"@babel/preset-typescript": "^7.26.0",
|
||||||
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
|
"@lingui/babel-plugin-lingui-macro": "^5.2.0",
|
||||||
"@lingui/vite-plugin": "^5.2.0",
|
"@lingui/vite-plugin": "^5.2.0",
|
||||||
"@react-router/dev": "^7.1.1",
|
"@react-router/dev": "^7.1.5",
|
||||||
"@react-router/remix-routes-option-adapter": "^7.1.5",
|
"@react-router/remix-routes-option-adapter": "^7.1.5",
|
||||||
"@rollup/plugin-babel": "^6.0.4",
|
"@rollup/plugin-babel": "^6.0.4",
|
||||||
"@rollup/plugin-commonjs": "^28.0.2",
|
"@rollup/plugin-commonjs": "^28.0.2",
|
||||||
@@ -902,9 +902,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apps/remix/node_modules/posthog-node": {
|
"apps/remix/node_modules/posthog-node": {
|
||||||
"version": "4.7.0",
|
"version": "4.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.8.1.tgz",
|
||||||
"integrity": "sha512-RgdUKSW8MfMOkjUa8cYVqWndNjPePNuuxlGbrZC6z1WRBsVc6TdGl8caidmC10RW8mu/BOfmrGbP4cRTo2jARg==",
|
"integrity": "sha512-ApMEC1+DbctP/88+VhaCl8SRKpIoReibMf7Mb3rxw3yMthr1rKaM4opbHdZJ0buLhwS5zX8B2ckqLjpwpSjRPg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.4"
|
"axios": "^1.7.4"
|
||||||
@@ -31060,9 +31060,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.223.3",
|
"version": "1.224.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.223.3.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.224.0.tgz",
|
||||||
"integrity": "sha512-ZQTc17M21IzkQmECJa2Xjont4tZrvIn252uGT3sTfmahTqZoW4j+kBj4eOJt9SNR6hOheFNkg7MSiI/rA6FaDA==",
|
"integrity": "sha512-JT1XQQeYs0CKb4lU2vujmeLTDLWc61I5lT7d6oG/H/cnCpXAqBi5rMuCFFeotHeMy3hqJ/Tpu3eAPFE2p5ErHA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
@@ -41567,6 +41567,7 @@
|
|||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"playwright": "1.43.0",
|
"playwright": "1.43.0",
|
||||||
|
"posthog-js": "^1.224.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"remeda": "^2.17.3",
|
"remeda": "^2.17.3",
|
||||||
"sharp": "0.32.6",
|
"sharp": "0.32.6",
|
||||||
|
|||||||
@@ -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 dev",
|
"d": "npm run dx && npm run translate:compile && 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fetchRequestHandler } from '@ts-rest/serverless/fetch';
|
import { TsRestHttpError, 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,6 +29,12 @@ 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
@@ -48,15 +50,9 @@ 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';
|
||||||
@@ -181,7 +177,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status !== DocumentStatus.COMPLETED) {
|
if (!isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -585,6 +581,7 @@ 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 +670,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -776,7 +773,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -867,7 +864,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -926,7 +923,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -991,7 +988,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: { message: 'Document is already completed' },
|
body: { message: 'Document is already completed' },
|
||||||
@@ -1153,7 +1150,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
@@ -1241,7 +1238,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.status === DocumentStatus.COMPLETED) {
|
if (isDocumentCompleted(document.status)) {
|
||||||
return {
|
return {
|
||||||
status: 400,
|
status: 400,
|
||||||
body: {
|
body: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
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';
|
||||||
@@ -11,19 +23,7 @@ import {
|
|||||||
ZRecipientActionAuthTypesSchema,
|
ZRecipientActionAuthTypesSchema,
|
||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldMetaPrefillFieldsSchema, 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.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
|
.or(z.any().transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
|
||||||
|
|
||||||
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||||
|
|
||||||
@@ -299,6 +299,7 @@ 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<
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ZFindTeamMembersResponseSchema,
|
ZFindTeamMembersResponseSchema,
|
||||||
@@ -10,7 +11,6 @@ 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';
|
||||||
|
|
||||||
|
|||||||
614
packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts
Normal file
614
packages/app-tests/e2e/api/v1/template-field-prefill.spec.ts
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
602
packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts
Normal file
602
packages/app-tests/e2e/api/v2/template-field-prefill.spec.ts
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 '@documenso/prisma/client';
|
} from '@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,7 +377,9 @@ 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.getByRole('button', { name: 'Complete' }).click();
|
await page
|
||||||
|
.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();
|
||||||
@@ -447,7 +449,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: 'Complete' }).click();
|
await page.getByRole('button', { name: 'Approve' }).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();
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
|
const enterprisePriceId = '';
|
||||||
|
|
||||||
test.beforeEach(() => {
|
test.beforeEach(() => {
|
||||||
test.skip(
|
test.skip(
|
||||||
|
|||||||
@@ -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 = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
|
const enterprisePriceId = '';
|
||||||
|
|
||||||
// Create a temporary PDF file for testing
|
// Create a temporary PDF file for testing
|
||||||
function createTempPdfFile() {
|
function createTempPdfFile() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { appLog } from '@documenso/lib/utils/debugger';
|
|||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
|
||||||
import { AUTH_SESSION_LIFETIME } from '../../config';
|
import { AUTH_SESSION_LIFETIME } from '../../config';
|
||||||
|
import { extractCookieFromHeaders } from '../utils/cookies';
|
||||||
import { generateSessionToken } from './session';
|
import { generateSessionToken } from './session';
|
||||||
|
|
||||||
export const sessionCookieName = formatSecureCookieName('sessionId');
|
export const sessionCookieName = formatSecureCookieName('sessionId');
|
||||||
@@ -38,15 +39,7 @@ export const sessionCookieOptions = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const extractSessionCookieFromHeaders = (headers: Headers): string | null => {
|
export const extractSessionCookieFromHeaders = (headers: Headers): string | null => {
|
||||||
const cookieHeader = headers.get('cookie') || '';
|
return extractCookieFromHeaders(sessionCookieName, headers);
|
||||||
const cookiePairs = cookieHeader.split(';');
|
|
||||||
const sessionCookie = cookiePairs.find((pair) => pair.trim().startsWith(sessionCookieName));
|
|
||||||
|
|
||||||
if (!sessionCookie) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionCookie.split('=')[1].trim();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
14
packages/auth/server/lib/utils/cookies.ts
Normal file
14
packages/auth/server/lib/utils/cookies.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Todo: Use library for cookies instead.
|
||||||
|
*/
|
||||||
|
export const extractCookieFromHeaders = (cookieName: string, headers: Headers): string | null => {
|
||||||
|
const cookieHeader = headers.get('cookie') || '';
|
||||||
|
const cookiePairs = cookieHeader.split(';');
|
||||||
|
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(cookieName));
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cookie.split('=')[1].trim();
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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';
|
||||||
@@ -6,7 +7,6 @@ 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';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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';
|
||||||
@@ -22,7 +23,6 @@ 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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ 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 = typeof plan === 'string' ? [plan] : plan;
|
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
|
||||||
|
|
||||||
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
|
const prices = await stripe.prices.list({
|
||||||
|
|
||||||
const { data: prices } = await stripe.prices.search({
|
|
||||||
query,
|
|
||||||
expand: ['data.product'],
|
expand: ['data.product'],
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
return prices.filter((price) => price.type === 'recurring');
|
return prices.data.filter(
|
||||||
|
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
|
|
||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export type OnSubscriptionDeletedOptions = {
|
export type OnSubscriptionDeletedOptions = {
|
||||||
subscription: Stripe.Subscription;
|
subscription: Stripe.Subscription;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { SubscriptionStatus } from '@prisma/client';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { Prisma } from '@documenso/prisma/client';
|
|
||||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
export type OnSubscriptionUpdatedOptions = {
|
export type OnSubscriptionUpdatedOptions = {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import type { Subscription } from '@prisma/client';
|
||||||
|
|
||||||
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 } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
|
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import type { Subscription } from '@prisma/client';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
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 } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { getEnterprisePlanPriceIds } from '../stripe/get-enterprise-plan-prices';
|
import { getEnterprisePlanPriceIds } from '../stripe/get-enterprise-plan-prices';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import type { Document, Subscription } from '@prisma/client';
|
||||||
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
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 { Document, Subscription } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
|
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ export interface TemplateDocumentCancelProps {
|
|||||||
inviterEmail: string;
|
inviterEmail: string;
|
||||||
documentName: string;
|
documentName: string;
|
||||||
assetBaseUrl: string;
|
assetBaseUrl: string;
|
||||||
|
cancellationReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateDocumentCancel = ({
|
export const TemplateDocumentCancel = ({
|
||||||
inviterName,
|
inviterName,
|
||||||
documentName,
|
documentName,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
|
cancellationReason,
|
||||||
}: TemplateDocumentCancelProps) => {
|
}: TemplateDocumentCancelProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -34,6 +36,12 @@ export const TemplateDocumentCancel = ({
|
|||||||
<Text className="my-1 text-center text-base text-slate-400">
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
<Trans>You don't need to sign it anymore.</Trans>
|
<Trans>You don't need to sign it anymore.</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{cancellationReason && (
|
||||||
|
<Text className="mt-4 text-center text-base">
|
||||||
|
<Trans>Reason for cancellation: {cancellationReason}</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useMemo } 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 { RecipientRole } from '@prisma/client';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, 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 { RecipientRole } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { Button, Section, Text } from '../components';
|
import { Button, Section, Text } from '../components';
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const DocumentCancelTemplate = ({
|
|||||||
inviterEmail = 'lucas@documenso.com',
|
inviterEmail = 'lucas@documenso.com',
|
||||||
documentName = 'Open Source Pledge.pdf',
|
documentName = 'Open Source Pledge.pdf',
|
||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
cancellationReason,
|
||||||
}: DocumentCancelEmailTemplateProps) => {
|
}: DocumentCancelEmailTemplateProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const branding = useBranding();
|
const branding = useBranding();
|
||||||
@@ -48,6 +49,7 @@ export const DocumentCancelTemplate = ({
|
|||||||
inviterEmail={inviterEmail}
|
inviterEmail={inviterEmail}
|
||||||
documentName={documentName}
|
documentName={documentName}
|
||||||
assetBaseUrl={assetBaseUrl}
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
cancellationReason={cancellationReason}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,9 +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 { RecipientRole } from '@prisma/client';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { RecipientRole } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { Body, Button, Container, Head, Html, Img, Preview, Section, Text } from '../components';
|
import { Body, Button, Container, Head, Html, Img, Preview, Section, Text } from '../components';
|
||||||
import { useBranding } from '../providers/branding';
|
import { useBranding } from '../providers/branding';
|
||||||
|
|||||||
@@ -1,9 +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 { RecipientRole } from '@prisma/client';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import type { RecipientRole } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components';
|
import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components';
|
||||||
import { useBranding } from '../providers/branding';
|
import { useBranding } from '../providers/branding';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user