mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
22 Commits
feat/publi
...
v1.5.6-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e304b37b2 | |||
| 1f3df51371 | |||
| 6e2363d48c | |||
| 64bec5f29c | |||
| 311328471e | |||
| d58a88196a | |||
| f1c6fc6fb7 | |||
| babdbccbd3 | |||
| 3e634fd975 | |||
| 4c0b772fc9 | |||
| 24b228acf7 | |||
| e072e270f8 | |||
| d37edc4351 | |||
| a877c64aca | |||
| 2f86bb523b | |||
| 788933b75d | |||
| bbcbc56e70 | |||
| 968b116012 | |||
| e4620efa4a | |||
| 84bbcea7bb | |||
| dca4b8eaec | |||
| 80c03fcf3f |
@ -75,7 +75,7 @@ NEXT_PRIVATE_SMTP_APIKEY=
|
|||||||
# OPTIONAL: Defines whether to force the use of TLS.
|
# OPTIONAL: Defines whether to force the use of TLS.
|
||||||
NEXT_PRIVATE_SMTP_SECURE=
|
NEXT_PRIVATE_SMTP_SECURE=
|
||||||
# REQUIRED: Defines the sender name to use for the from address.
|
# REQUIRED: Defines the sender name to use for the from address.
|
||||||
NEXT_PRIVATE_SMTP_FROM_NAME="No Reply @ Documenso"
|
NEXT_PRIVATE_SMTP_FROM_NAME="Documenso"
|
||||||
# REQUIRED: Defines the email address to use as the from address.
|
# REQUIRED: Defines the email address to use as the from address.
|
||||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
|
||||||
# OPTIONAL: The API key to use for Resend.com
|
# OPTIONAL: The API key to use for Resend.com
|
||||||
|
|||||||
20
.gitpod.yml
20
.gitpod.yml
@ -6,7 +6,7 @@ tasks:
|
|||||||
set -a; source .env &&
|
set -a; source .env &&
|
||||||
export NEXTAUTH_URL="$(gp url 3000)" &&
|
export NEXTAUTH_URL="$(gp url 3000)" &&
|
||||||
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
||||||
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
||||||
command: npm run d
|
command: npm run d
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
@ -25,20 +25,10 @@ ports:
|
|||||||
- port: 2500
|
- port: 2500
|
||||||
visibility: private
|
visibility: private
|
||||||
onOpen: ignore
|
onOpen: ignore
|
||||||
- port: 54320
|
- port: 54320
|
||||||
visibility: private
|
visibility: private
|
||||||
onOpen: ignore
|
onOpen: ignore
|
||||||
|
|
||||||
|
|
||||||
github:
|
|
||||||
prebuilds:
|
|
||||||
master: true
|
|
||||||
pullRequests: true
|
|
||||||
pullRequestsFromForks: true
|
|
||||||
addCheck: true
|
|
||||||
addComment: true
|
|
||||||
addBadge: true
|
|
||||||
|
|
||||||
vscode:
|
vscode:
|
||||||
extensions:
|
extensions:
|
||||||
- aaron-bond.better-comments
|
- aaron-bond.better-comments
|
||||||
@ -47,9 +37,5 @@ vscode:
|
|||||||
- esbenp.prettier-vscode
|
- esbenp.prettier-vscode
|
||||||
- mikestead.dotenv
|
- mikestead.dotenv
|
||||||
- unifiedjs.vscode-mdx
|
- unifiedjs.vscode-mdx
|
||||||
- GitHub.copilot-chat
|
|
||||||
- GitHub.copilot-labs
|
|
||||||
- GitHub.copilot
|
|
||||||
- GitHub.vscode-pull-request-github
|
- GitHub.vscode-pull-request-github
|
||||||
- Prisma.prisma
|
- Prisma.prisma
|
||||||
- VisualStudioExptTeam.vscodeintellicode
|
|
||||||
|
|||||||
@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
|||||||
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
@ -38,6 +42,7 @@ const config = {
|
|||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_PROJECT: 'marketing',
|
NEXT_PUBLIC_PROJECT: 'marketing',
|
||||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
|
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
2
apps/marketing/public/pdf.worker.min.js
vendored
2
apps/marketing/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
|||||||
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FONT_NOTO_SANS_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/noto-sans.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
@ -42,6 +46,7 @@ const config = {
|
|||||||
APP_VERSION: version,
|
APP_VERSION: version,
|
||||||
NEXT_PUBLIC_PROJECT: 'web',
|
NEXT_PUBLIC_PROJECT: 'web',
|
||||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
|
FONT_NOTO_SANS_URI: `data:font/ttf;base64,${FONT_NOTO_SANS_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
56591
apps/web/public/pdf.worker.min.js
vendored
56591
apps/web/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { type Document, DocumentStatus } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { type Document, SigningStatus } from '@documenso/prisma/client';
|
||||||
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';
|
||||||
@ -17,9 +18,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
export type AdminActionsProps = {
|
export type AdminActionsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
document: Document;
|
document: Document;
|
||||||
|
recipients: Recipient[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminActions = ({ className, document }: AdminActionsProps) => {
|
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
|
||||||
@ -47,7 +49,9 @@ export const AdminActions = ({ className, document }: AdminActionsProps) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
loading={isResealDocumentLoading}
|
loading={isResealDocumentLoading}
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
disabled={recipients.some(
|
||||||
|
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
|
||||||
|
)}
|
||||||
onClick={() => resealDocument({ id: document.id })}
|
onClick={() => resealDocument({ id: document.id })}
|
||||||
>
|
>
|
||||||
Reseal document
|
Reseal document
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
|||||||
|
|
||||||
<h2 className="text-lg font-semibold">Admin Actions</h2>
|
<h2 className="text-lg font-semibold">Admin Actions</h2>
|
||||||
|
|
||||||
<AdminActions className="mt-2" document={document} />
|
<AdminActions className="mt-2" document={document} recipients={document.Recipient} />
|
||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
<h2 className="text-lg font-semibold">Recipients</h2>
|
<h2 className="text-lg font-semibold">Recipients</h2>
|
||||||
|
|||||||
@ -332,6 +332,7 @@ export const EditDocumentForm = ({
|
|||||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
onSubmit={onAddSettingsFormSubmit}
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
|
|||||||
@ -36,11 +36,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const isDocumentEnterprise = await isUserEnterprise({
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const document = await getDocumentWithDetailsById({
|
const document = await getDocumentWithDetailsById({
|
||||||
id: documentId,
|
id: documentId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@ -74,6 +69,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
documentMeta.password = securePassword;
|
documentMeta.password = securePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDocumentEnterprise = await isUserEnterprise({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
@ -62,7 +63,12 @@ export const DocumentsDataTable = ({
|
|||||||
{
|
{
|
||||||
header: 'Created',
|
header: 'Created',
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
cell: ({ row }) => (
|
||||||
|
<LocaleDate
|
||||||
|
date={row.original.createdAt}
|
||||||
|
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Title',
|
header: 'Title',
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
|
import {
|
||||||
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
SKIP_QUERY_BATCH_META,
|
||||||
|
} from '@documenso/lib/constants/trpc';
|
||||||
|
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||||
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 { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -19,52 +23,135 @@ import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-
|
|||||||
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';
|
||||||
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
|
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
|
||||||
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
|
||||||
|
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
|
||||||
|
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type EditTemplateFormProps = {
|
export type EditTemplateFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
initialTemplate: TemplateWithDetails;
|
||||||
template: Template;
|
isEnterprise: boolean;
|
||||||
recipients: Recipient[];
|
|
||||||
fields: Field[];
|
|
||||||
documentData: DocumentData;
|
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditTemplateStep = 'signers' | 'fields';
|
type EditTemplateStep = 'settings' | 'signers' | 'fields';
|
||||||
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
|
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
|
||||||
|
|
||||||
export const EditTemplateForm = ({
|
export const EditTemplateForm = ({
|
||||||
|
initialTemplate,
|
||||||
className,
|
className,
|
||||||
template,
|
isEnterprise,
|
||||||
recipients,
|
|
||||||
fields,
|
|
||||||
user: _user,
|
|
||||||
documentData,
|
|
||||||
templateRootPath,
|
templateRootPath,
|
||||||
}: EditTemplateFormProps) => {
|
}: EditTemplateFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [step, setStep] = useState<EditTemplateStep>('signers');
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
|
const [step, setStep] = useState<EditTemplateStep>('settings');
|
||||||
|
|
||||||
|
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const { data: template, refetch: refetchTemplate } =
|
||||||
|
trpc.template.getTemplateWithDetailsById.useQuery(
|
||||||
|
{
|
||||||
|
id: initialTemplate.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialData: initialTemplate,
|
||||||
|
...SKIP_QUERY_BATCH_META,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { Recipient: recipients, Field: fields, templateDocumentData } = template;
|
||||||
|
|
||||||
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
|
||||||
|
settings: {
|
||||||
|
title: 'General',
|
||||||
|
description: 'Configure general settings for the template.',
|
||||||
|
stepIndex: 1,
|
||||||
|
},
|
||||||
signers: {
|
signers: {
|
||||||
title: 'Add Placeholders',
|
title: 'Add Placeholders',
|
||||||
description: 'Add all relevant placeholders for each recipient.',
|
description: 'Add all relevant placeholders for each recipient.',
|
||||||
stepIndex: 1,
|
stepIndex: 2,
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
title: 'Add Fields',
|
title: 'Add Fields',
|
||||||
description: 'Add all relevant fields for each recipient.',
|
description: 'Add all relevant fields for each recipient.',
|
||||||
stepIndex: 2,
|
stepIndex: 3,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentDocumentFlow = documentFlow[step];
|
const currentDocumentFlow = documentFlow[step];
|
||||||
|
|
||||||
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
|
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplateSettings.useMutation({
|
||||||
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newData) => {
|
||||||
|
utils.template.getTemplateWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialTemplate.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newData) => {
|
||||||
|
utils.template.getTemplateWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialTemplate.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (newData) => {
|
||||||
|
utils.template.getTemplateWithDetailsById.setData(
|
||||||
|
{
|
||||||
|
id: initialTemplate.id,
|
||||||
|
},
|
||||||
|
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
|
||||||
|
try {
|
||||||
|
await updateTemplateSettings({
|
||||||
|
templateId: template.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||||
|
globalActionAuth: data.globalActionAuth ?? null,
|
||||||
|
},
|
||||||
|
meta: data.meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
setStep('signers');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An error occurred while updating the document settings.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onAddTemplatePlaceholderFormSubmit = async (
|
const onAddTemplatePlaceholderFormSubmit = async (
|
||||||
data: TAddTemplatePlacholderRecipientsFormSchema,
|
data: TAddTemplatePlacholderRecipientsFormSchema,
|
||||||
@ -72,9 +159,11 @@ export const EditTemplateForm = ({
|
|||||||
try {
|
try {
|
||||||
await addTemplateSigners({
|
await addTemplateSigners({
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
|
teamId: team?.id,
|
||||||
signers: data.signers,
|
signers: data.signers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('fields');
|
setStep('fields');
|
||||||
@ -100,6 +189,9 @@ export const EditTemplateForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
router.push(templateRootPath);
|
router.push(templateRootPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
@ -110,6 +202,15 @@ export const EditTemplateForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the data in the background when steps change.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
void refetchTemplate();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||||
<Card
|
<Card
|
||||||
@ -117,7 +218,11 @@ export const EditTemplateForm = ({
|
|||||||
gradient
|
gradient
|
||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
<LazyPDFViewer
|
||||||
|
key={templateDocumentData.id}
|
||||||
|
documentData={templateDocumentData}
|
||||||
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -135,14 +240,25 @@ export const EditTemplateForm = ({
|
|||||||
currentStep={currentDocumentFlow.stepIndex}
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
|
||||||
>
|
>
|
||||||
|
<AddTemplateSettingsFormPartial
|
||||||
|
key={recipients.length}
|
||||||
|
template={template}
|
||||||
|
documentFlow={documentFlow.settings}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
|
isEnterprise={isEnterprise}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
|
/>
|
||||||
|
|
||||||
<AddTemplatePlaceholderRecipientsFormPartial
|
<AddTemplatePlaceholderRecipientsFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||||
// Todo: Add when we setup template settings.
|
isEnterprise={isEnterprise}
|
||||||
isTemplateOwnerEnterprise={false}
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddTemplateFieldsFormPartial
|
<AddTemplateFieldsFormPartial
|
||||||
|
|||||||
@ -5,10 +5,9 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
|
||||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
|
||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
import type { Team } from '@documenso/prisma/client';
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
|
||||||
@ -35,7 +34,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const template = await getTemplateById({
|
const template = await getTemplateWithDetailsById({
|
||||||
id: templateId,
|
id: templateId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
@ -44,18 +43,10 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
redirect(templateRootPath);
|
redirect(templateRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { templateDocumentData } = template;
|
const isTemplateEnterprise = await isUserEnterprise({
|
||||||
|
userId: user.id,
|
||||||
const [templateRecipients, templateFields] = await Promise.all([
|
teamId: team?.id,
|
||||||
getRecipientsForTemplate({
|
});
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
getFieldsForTemplate({
|
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
||||||
@ -74,12 +65,9 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
|||||||
|
|
||||||
<EditTemplateForm
|
<EditTemplateForm
|
||||||
className="mt-6"
|
className="mt-6"
|
||||||
template={template}
|
initialTemplate={template}
|
||||||
user={user}
|
|
||||||
recipients={templateRecipients}
|
|
||||||
fields={templateFields}
|
|
||||||
documentData={templateDocumentData}
|
|
||||||
templateRootPath={templateRootPath}
|
templateRootPath={templateRootPath}
|
||||||
|
isEnterprise={isTemplateEnterprise}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,21 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { FilePlus, Loader } from 'lucide-react';
|
||||||
import { FilePlus, X } from 'lucide-react';
|
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import * as z from 'zod';
|
|
||||||
|
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@ -27,24 +22,8 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ZCreateTemplateFormSchema = z.object({
|
|
||||||
name: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
|
||||||
|
|
||||||
type NewTemplateDialogProps = {
|
type NewTemplateDialogProps = {
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
templateRootPath: string;
|
templateRootPath: string;
|
||||||
@ -56,50 +35,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const form = useForm<TCreateTemplateFormSchema>({
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZCreateTemplateFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||||
|
|
||||||
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
|
||||||
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
|
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
if (isUploadingFile) {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const base64String = base64.encode(new Uint8Array(arrayBuffer));
|
|
||||||
|
|
||||||
setUploadedFile({
|
|
||||||
file,
|
|
||||||
fileBase64: `data:application/pdf;base64,${base64String}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!form.getValues('name')) {
|
|
||||||
form.setValue('name', file.name);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'Please try again later.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (values: TCreateTemplateFormSchema) => {
|
|
||||||
if (!uploadedFile) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const file: File = uploadedFile.file;
|
setIsUploadingFile(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { type, data } = await putPdfFile(file);
|
const { type, data } = await putPdfFile(file);
|
||||||
|
|
||||||
const { id: templateDocumentDataId } = await createDocumentData({
|
const { id: templateDocumentDataId } = await createDocumentData({
|
||||||
type,
|
type,
|
||||||
data,
|
data,
|
||||||
@ -107,7 +56,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
|
|
||||||
const { id } = await createTemplate({
|
const { id } = await createTemplate({
|
||||||
teamId,
|
teamId,
|
||||||
title: values.name ? values.name : file.name,
|
title: file.name,
|
||||||
templateDocumentDataId,
|
templateDocumentDataId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -127,26 +76,16 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
description: 'Please try again later.',
|
description: 'Please try again later.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setIsUploadingFile(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
if (form.getValues('name') === uploadedFile?.file.name) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadedFile(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showNewTemplateDialog) {
|
|
||||||
form.reset();
|
|
||||||
setUploadedFile(null);
|
|
||||||
}
|
|
||||||
}, [form, showNewTemplateDialog]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
|
<Dialog
|
||||||
|
open={showNewTemplateDialog}
|
||||||
|
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
|
||||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||||
@ -162,80 +101,23 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<div className="relative">
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="flex flex-col gap-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Template name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
Leave this empty if you would like to use your document's name for the
|
|
||||||
template
|
|
||||||
</span>
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-1.5">
|
{isUploadingFile && (
|
||||||
{uploadedFile ? (
|
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||||
<Card gradient className="h-[40vh]">
|
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||||
<CardContent className="flex h-full flex-col items-center justify-center p-2">
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={() => resetForm()}
|
</div>
|
||||||
title="Remove Template"
|
|
||||||
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
<span className="sr-only">Remove Template</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
|
<DialogFooter>
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
<DialogClose asChild>
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
|
<Button type="button" variant="secondary" disabled={isUploadingFile}>
|
||||||
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
|
Close
|
||||||
</div>
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
|
</DialogFooter>
|
||||||
Uploaded Document
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground/80 mt-1 text-sm">
|
|
||||||
{uploadedFile.file.name}
|
|
||||||
</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button type="button" variant="secondary">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
disabled={!uploadedFile}
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Create template
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -41,7 +41,7 @@ volumes:
|
|||||||
1. Run the following command to start the containers:
|
1. Run the following command to start the containers:
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose --env-file ./.env -d up
|
docker-compose --env-file ./.env up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This will start the PostgreSQL database and the Documenso application containers.
|
This will start the PostgreSQL database and the Documenso application containers.
|
||||||
|
|||||||
@ -58,7 +58,7 @@ services:
|
|||||||
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
|
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
|
||||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||||
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
|
- NEXT_PUBLIC_DISABLE_SIGNUP=${NEXT_PUBLIC_DISABLE_SIGNUP}
|
||||||
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?:-/opt/documenso/cert.p12}
|
- NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}
|
||||||
ports:
|
ports:
|
||||||
- ${PORT:-3000}:${PORT:-3000}
|
- ${PORT:-3000}:${PORT:-3000}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
108
package-lock.json
generated
108
package-lock.json
generated
@ -17536,6 +17536,7 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
|
||||||
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
|
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
|
||||||
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@ -17580,18 +17581,15 @@
|
|||||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||||
},
|
},
|
||||||
"node_modules/pdfjs-dist": {
|
"node_modules/pdfjs-dist": {
|
||||||
"version": "3.6.172",
|
"version": "3.11.174",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.6.172.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||||
"integrity": "sha512-bfOhCg+S9DXh/ImWhWYTOiq3aVMFSCvzGiBzsIJtdMC71kVWDBw7UXr32xh0y56qc5wMVylIeqV3hBaRsu+e+w==",
|
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
|
||||||
"dependencies": {
|
|
||||||
"path2d-polyfill": "^2.0.1",
|
|
||||||
"web-streams-polyfill": "^3.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"canvas": "^2.11.2"
|
"canvas": "^2.11.2",
|
||||||
|
"path2d-polyfill": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/peberminta": {
|
"node_modules/peberminta": {
|
||||||
@ -19011,42 +19009,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||||
},
|
},
|
||||||
"node_modules/react-pdf": {
|
|
||||||
"version": "7.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.3.3.tgz",
|
|
||||||
"integrity": "sha512-d7WAxcsjOogJfJ+I+zX/mdip3VjR1yq/yDa4hax4XbQVjbbbup6rqs4c8MGx0MLSnzob17TKp1t4CsNbDZ6GeQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"clsx": "^2.0.0",
|
|
||||||
"make-cancellable-promise": "^1.3.1",
|
|
||||||
"make-event-props": "^1.6.0",
|
|
||||||
"merge-refs": "^1.2.1",
|
|
||||||
"pdfjs-dist": "3.6.172",
|
|
||||||
"prop-types": "^15.6.2",
|
|
||||||
"tiny-invariant": "^1.0.0",
|
|
||||||
"tiny-warning": "^1.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-pdf/node_modules/clsx": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-property": {
|
"node_modules/react-property": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz",
|
||||||
@ -21357,11 +21319,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
|
||||||
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
|
"integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw=="
|
||||||
},
|
},
|
||||||
"node_modules/tiny-warning": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
|
|
||||||
},
|
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz",
|
||||||
@ -22986,6 +22943,14 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/warning": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/watchpack": {
|
"node_modules/watchpack": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
|
||||||
@ -25390,11 +25355,13 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.2",
|
"luxon": "^3.4.2",
|
||||||
"next": "14.0.3",
|
"next": "14.0.3",
|
||||||
"pdfjs-dist": "3.6.172",
|
"pdfjs-dist": "3.11.174",
|
||||||
|
"react": "18.2.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-day-picker": "^8.7.1",
|
"react-day-picker": "^8.7.1",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.45.4",
|
"react-hook-form": "^7.45.4",
|
||||||
"react-pdf": "7.3.3",
|
"react-pdf": "7.7.3",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"tailwind-merge": "^1.12.0",
|
"tailwind-merge": "^1.12.0",
|
||||||
"tailwindcss-animate": "^1.0.5",
|
"tailwindcss-animate": "^1.0.5",
|
||||||
@ -25411,6 +25378,43 @@
|
|||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/ui/node_modules/react-pdf": {
|
||||||
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-a2VfDl8hiGjugpqezBTUzJHYLNB7IS7a2t7GD52xMI9xHg8LdVaTMsnM9ZlNmKadnStT/tvX5IfV0yLn+JvYmw==",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"make-cancellable-promise": "^1.3.1",
|
||||||
|
"make-event-props": "^1.6.0",
|
||||||
|
"merge-refs": "^1.2.1",
|
||||||
|
"pdfjs-dist": "3.11.174",
|
||||||
|
"prop-types": "^15.6.2",
|
||||||
|
"tiny-invariant": "^1.0.0",
|
||||||
|
"warning": "^4.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/ui/node_modules/react-pdf/node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/ui/node_modules/typescript": {
|
"packages/ui/node_modules/typescript": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import {
|
|||||||
ZDeleteFieldMutationSchema,
|
ZDeleteFieldMutationSchema,
|
||||||
ZDeleteRecipientMutationSchema,
|
ZDeleteRecipientMutationSchema,
|
||||||
ZDownloadDocumentSuccessfulSchema,
|
ZDownloadDocumentSuccessfulSchema,
|
||||||
|
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||||
|
ZGenerateDocumentFromTemplateMutationSchema,
|
||||||
ZGetDocumentsQuerySchema,
|
ZGetDocumentsQuerySchema,
|
||||||
ZSendDocumentForSigningMutationSchema,
|
ZSendDocumentForSigningMutationSchema,
|
||||||
ZSuccessfulDocumentResponseSchema,
|
ZSuccessfulDocumentResponseSchema,
|
||||||
@ -85,6 +87,24 @@ export const ApiContractV1 = c.router(
|
|||||||
404: ZUnsuccessfulResponseSchema,
|
404: ZUnsuccessfulResponseSchema,
|
||||||
},
|
},
|
||||||
summary: 'Create a new document from an existing template',
|
summary: 'Create a new document from an existing template',
|
||||||
|
deprecated: true,
|
||||||
|
description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`,
|
||||||
|
},
|
||||||
|
|
||||||
|
generateDocumentFromTemplate: {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/templates/:templateId/generate-document',
|
||||||
|
body: ZGenerateDocumentFromTemplateMutationSchema,
|
||||||
|
responses: {
|
||||||
|
200: ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||||
|
400: ZUnsuccessfulResponseSchema,
|
||||||
|
401: ZUnsuccessfulResponseSchema,
|
||||||
|
404: ZUnsuccessfulResponseSchema,
|
||||||
|
500: ZUnsuccessfulResponseSchema,
|
||||||
|
},
|
||||||
|
summary: 'Create a new document from an existing template',
|
||||||
|
description:
|
||||||
|
'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.',
|
||||||
},
|
},
|
||||||
|
|
||||||
sendDocument: {
|
sendDocument: {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { createNextRoute } from '@ts-rest/next';
|
import { createNextRoute } from '@ts-rest/next';
|
||||||
|
|
||||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||||
@ -19,6 +20,8 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
|
|||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
|
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
|
||||||
|
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||||
|
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||||
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
|
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
|
||||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
@ -351,6 +354,85 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
|
||||||
|
const { body, params } = args;
|
||||||
|
|
||||||
|
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
||||||
|
|
||||||
|
if (remaining.documents <= 0) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'You have reached the maximum number of documents allowed for this month',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateId = Number(params.templateId);
|
||||||
|
|
||||||
|
let document: CreateDocumentFromTemplateResponse | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
document = await createDocumentFromTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
recipients: body.recipients,
|
||||||
|
override: {
|
||||||
|
title: body.title,
|
||||||
|
...body.meta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return AppError.toRestAPIError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.formValues) {
|
||||||
|
const fileName = document.title.endsWith('.pdf') ? document.title : `${document.title}.pdf`;
|
||||||
|
|
||||||
|
const pdf = await getFile(document.documentData);
|
||||||
|
|
||||||
|
const prefilled = await insertFormValuesInPdf({
|
||||||
|
pdf: Buffer.from(pdf),
|
||||||
|
formValues: body.formValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newDocumentData = await putPdfFile({
|
||||||
|
name: fileName,
|
||||||
|
type: 'application/pdf',
|
||||||
|
arrayBuffer: async () => Promise.resolve(prefilled),
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateDocument({
|
||||||
|
documentId: document.id,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
data: {
|
||||||
|
formValues: body.formValues,
|
||||||
|
documentData: {
|
||||||
|
connect: {
|
||||||
|
id: newDocumentData.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
documentId: document.id,
|
||||||
|
recipients: document.Recipient.map((recipient) => ({
|
||||||
|
recipientId: recipient.id,
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
token: recipient.token,
|
||||||
|
role: recipient.role,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
sendDocument: authenticatedMiddleware(async (args, user, team) => {
|
sendDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
const { id } = args.params;
|
const { id } = args.params;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ZUrlSchema } from '@documenso/lib/schemas/common';
|
||||||
import {
|
import {
|
||||||
FieldType,
|
FieldType,
|
||||||
ReadStatus,
|
ReadStatus,
|
||||||
@ -141,6 +142,59 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
|
|||||||
typeof ZCreateDocumentFromTemplateMutationResponseSchema
|
typeof ZCreateDocumentFromTemplateMutationResponseSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
recipients: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(schema) => {
|
||||||
|
const emails = schema.map((signer) => signer.email.toLowerCase());
|
||||||
|
const ids = schema.map((signer) => signer.id);
|
||||||
|
|
||||||
|
return new Set(emails).size === emails.length && new Set(ids).size === ids.length;
|
||||||
|
},
|
||||||
|
{ message: 'Recipient IDs and emails must be unique' },
|
||||||
|
),
|
||||||
|
meta: z
|
||||||
|
.object({
|
||||||
|
subject: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
timezone: z.string(),
|
||||||
|
dateFormat: z.string(),
|
||||||
|
redirectUrl: ZUrlSchema,
|
||||||
|
})
|
||||||
|
.partial()
|
||||||
|
.optional(),
|
||||||
|
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<
|
||||||
|
typeof ZGenerateDocumentFromTemplateMutationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||||
|
documentId: z.number(),
|
||||||
|
recipients: z.array(
|
||||||
|
z.object({
|
||||||
|
recipientId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email().min(1),
|
||||||
|
token: z.string(),
|
||||||
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TGenerateDocumentFromTemplateMutationResponseSchema = z.infer<
|
||||||
|
typeof ZGenerateDocumentFromTemplateMutationResponseSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const ZCreateRecipientMutationSchema = z.object({
|
export const ZCreateRecipientMutationSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
|
|||||||
@ -41,8 +41,8 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
|
|
||||||
// Set EE action auth.
|
// Set EE action auth.
|
||||||
await page.getByTestId('documentActionSelectValue').click();
|
await page.getByTestId('documentActionSelectValue').click();
|
||||||
await page.getByLabel('Require account').getByText('Require account').click();
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
|
||||||
// Save the settings by going to the next step.
|
// Save the settings by going to the next step.
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
@ -52,11 +52,7 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
// Todo: Verify that the values are correct once we fix the issue where going back
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
// does not show the updated values.
|
|
||||||
// await expect(page.getByLabel('Title')).toContainText('New Title');
|
|
||||||
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
|
||||||
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
|
||||||
|
|
||||||
await unseedUser(user.id);
|
await unseedUser(user.id);
|
||||||
});
|
});
|
||||||
@ -89,8 +85,8 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
|
|
||||||
// Set EE action auth.
|
// Set EE action auth.
|
||||||
await page.getByTestId('documentActionSelectValue').click();
|
await page.getByTestId('documentActionSelectValue').click();
|
||||||
await page.getByLabel('Require account').getByText('Require account').click();
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
|
||||||
// Save the settings by going to the next step.
|
// Save the settings by going to the next step.
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
@ -168,11 +164,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
// Todo: Verify that the values are correct once we fix the issue where going back
|
await expect(page.getByLabel('Title')).toHaveValue('New Title');
|
||||||
// does not show the updated values.
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
// await expect(page.getByLabel('Title')).toContainText('New Title');
|
|
||||||
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
|
||||||
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
|
|
||||||
|
|
||||||
await unseedUser(user.id);
|
await unseedUser(user.id);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -48,7 +48,7 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||||
|
|
||||||
// Display advanced settings.
|
// Display advanced settings.
|
||||||
await page.getByLabel('Show advanced settings').click();
|
await page.getByLabel('Show advanced settings').check();
|
||||||
|
|
||||||
// Navigate to the next step and back.
|
// Navigate to the next step and back.
|
||||||
await page.getByRole('button', { name: 'Continue' }).click();
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
@ -62,7 +62,6 @@ test.describe('[EE_ONLY]', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: Not complete yet due to issue with back button.
|
|
||||||
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||||
const user = await seedUser();
|
const user = await seedUser();
|
||||||
const document = await seedBlankDocument(user);
|
const document = await seedBlankDocument(user);
|
||||||
@ -93,26 +92,5 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Go Back' }).click();
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
|
||||||
// Todo: Fix stepper component back issue before finishing test.
|
|
||||||
|
|
||||||
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
|
|
||||||
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
|
|
||||||
|
|
||||||
// // Add advanced settings for a single recipient.
|
|
||||||
// await page.getByLabel('Show advanced settings').click();
|
|
||||||
// await page.getByRole('combobox').first().click();
|
|
||||||
// await page.getByLabel('Require account').click();
|
|
||||||
|
|
||||||
// // Navigate to the next step and back.
|
|
||||||
// await page.getByRole('button', { name: 'Continue' }).click();
|
|
||||||
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
|
||||||
// await page.getByRole('button', { name: 'Go Back' }).click();
|
|
||||||
// await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
|
||||||
|
|
||||||
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
|
|
||||||
// settings were applied.
|
|
||||||
|
|
||||||
// Todo: Fix stepper component back issue before finishing test.
|
|
||||||
|
|
||||||
await unseedUser(user.id);
|
await unseedUser(user.id);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,167 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
|
||||||
|
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test.describe('[EE_ONLY]', () => {
|
||||||
|
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
test.skip(
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
|
||||||
|
'Billing required for this test',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW] add action auth settings', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
await seedUserSubscription({
|
||||||
|
userId: user.id,
|
||||||
|
priceId: enterprisePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set EE action auth.
|
||||||
|
await page.getByTestId('documentActionSelectValue').click();
|
||||||
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
|
||||||
|
// Save the settings by going to the next step.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||||
|
|
||||||
|
// Return to the settings step to check that the results are saved correctly.
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const owner = team.owner;
|
||||||
|
const teamMemberUser = team.members[1].user;
|
||||||
|
|
||||||
|
// Make the team enterprise by giving the owner the enterprise subscription.
|
||||||
|
await seedUserSubscription({
|
||||||
|
userId: team.ownerUserId,
|
||||||
|
priceId: enterprisePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = await seedBlankTemplate(owner, {
|
||||||
|
createTemplateOptions: {
|
||||||
|
teamId: team.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: teamMemberUser.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates/${template.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set EE action auth.
|
||||||
|
await page.getByTestId('documentActionSelectValue').click();
|
||||||
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
|
||||||
|
// Save the settings by going to the next step.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||||
|
|
||||||
|
// Advanced settings should be visible.
|
||||||
|
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMemberUser = team.members[1].user;
|
||||||
|
|
||||||
|
// Make the team enterprise by giving the owner the enterprise subscription.
|
||||||
|
await seedUserSubscription({
|
||||||
|
userId: team.ownerUserId,
|
||||||
|
priceId: enterprisePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = await seedBlankTemplate(teamMemberUser);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: teamMemberUser.email,
|
||||||
|
redirectPath: `/templates/${template.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global action auth should not be visible.
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Next step.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||||
|
|
||||||
|
// Advanced settings should not be visible.
|
||||||
|
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set title.
|
||||||
|
await page.getByLabel('Title').fill('New Title');
|
||||||
|
|
||||||
|
// Set access auth.
|
||||||
|
await page.getByTestId('documentAccessSelectValue').click();
|
||||||
|
await page.getByLabel('Require account').getByText('Require account').click();
|
||||||
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
// Action auth should NOT be visible.
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Save the settings by going to the next step.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
|
||||||
|
|
||||||
|
// Return to the settings step to check that the results are saved correctly.
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Title')).toHaveValue('New Title');
|
||||||
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
|
||||||
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test.describe('[EE_ONLY]', () => {
|
||||||
|
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
test.skip(
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
|
||||||
|
'Billing required for this test',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW] add EE settings', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
await seedUserSubscription({
|
||||||
|
userId: user.id,
|
||||||
|
priceId: enterprisePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save the settings by going to the next step.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add 2 signers.
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: 'Email', exact: true })
|
||||||
|
.fill('recipient2@documenso.com');
|
||||||
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||||
|
|
||||||
|
// Display advanced settings.
|
||||||
|
await page.getByLabel('Show advanced settings').check();
|
||||||
|
|
||||||
|
// Navigate to the next step and back.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// Expect that the advanced settings is unchecked, since no advanced settings were applied.
|
||||||
|
await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
|
||||||
|
|
||||||
|
// Add advanced settings for a single recipient.
|
||||||
|
await page.getByLabel('Show advanced settings').check();
|
||||||
|
await page.getByRole('combobox').first().click();
|
||||||
|
await page.getByLabel('Require passkey').click();
|
||||||
|
|
||||||
|
// Navigate to the next step and back.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Go Back' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
|
||||||
|
// settings were applied.
|
||||||
|
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save the settings by going to the next step.
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add 2 signers.
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||||
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||||
|
|
||||||
|
// Advanced settings should not be visible for non EE users.
|
||||||
|
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
@ -0,0 +1,285 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
|
||||||
|
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. Create a template with all settings filled out
|
||||||
|
* 2. Create a document from the template
|
||||||
|
* 3. Ensure all values are correct
|
||||||
|
*
|
||||||
|
* Note: There is a direct copy paste of this test below for teams.
|
||||||
|
*
|
||||||
|
* If you update this test please update that test as well.
|
||||||
|
*/
|
||||||
|
test('[TEMPLATE]: should create a document from a template', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const template = await seedBlankTemplate(user);
|
||||||
|
|
||||||
|
const isBillingEnabled =
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||||
|
|
||||||
|
await seedUserSubscription({
|
||||||
|
userId: user.id,
|
||||||
|
priceId: enterprisePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/templates/${template.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set template title.
|
||||||
|
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
|
||||||
|
|
||||||
|
// Set template document access.
|
||||||
|
await page.getByTestId('documentAccessSelectValue').click();
|
||||||
|
await page.getByLabel('Require account').getByText('Require account').click();
|
||||||
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
// Set EE action auth.
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
await page.getByTestId('documentActionSelectValue').click();
|
||||||
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set email options.
|
||||||
|
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||||
|
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||||
|
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||||
|
|
||||||
|
// Set advanced options.
|
||||||
|
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||||
|
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||||
|
await page.getByLabel('DD/MM/YYYY').click();
|
||||||
|
|
||||||
|
await page.locator('.time-zone-field').click();
|
||||||
|
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||||
|
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add 2 signers.
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||||
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||||
|
|
||||||
|
// Apply require passkey for Recipient 1.
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
await page.getByLabel('Show advanced settings').check();
|
||||||
|
await page.getByRole('combobox').first().click();
|
||||||
|
await page.getByLabel('Require passkey').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save template' }).click();
|
||||||
|
|
||||||
|
// Use template
|
||||||
|
await page.waitForURL('/templates');
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
|
// Review that the document was created with the correct values.
|
||||||
|
await page.waitForURL(/documents/);
|
||||||
|
|
||||||
|
const documentId = Number(page.url().split('/').pop());
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||||
|
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||||
|
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||||
|
isBillingEnabled ? 'PASSKEY' : null,
|
||||||
|
);
|
||||||
|
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||||
|
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||||
|
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||||
|
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||||
|
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||||
|
|
||||||
|
const recipientOne = document.Recipient[0];
|
||||||
|
const recipientTwo = document.Recipient[1];
|
||||||
|
|
||||||
|
const recipientOneAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipientOne.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientTwoAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipientTwo.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||||
|
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a direct copy paste of the above test but for teams.
|
||||||
|
*/
|
||||||
|
test('[TEMPLATE]: should create a team document from a team template', async ({ page }) => {
|
||||||
|
const { owner, ...team } = await seedTeam({
|
||||||
|
createTeamMembers: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const template = await seedBlankTemplate(owner, {
|
||||||
|
createTemplateOptions: {
|
||||||
|
teamId: team.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBillingEnabled =
|
||||||
|
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
|
||||||
|
|
||||||
|
await seedUserSubscription({
|
||||||
|
userId: owner.id,
|
||||||
|
priceId: enterprisePriceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: owner.email,
|
||||||
|
redirectPath: `/t/${team.url}/templates/${template.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set template title.
|
||||||
|
await page.getByLabel('Title').fill('TEMPLATE_TITLE');
|
||||||
|
|
||||||
|
// Set template document access.
|
||||||
|
await page.getByTestId('documentAccessSelectValue').click();
|
||||||
|
await page.getByLabel('Require account').getByText('Require account').click();
|
||||||
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
// Set EE action auth.
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
await page.getByTestId('documentActionSelectValue').click();
|
||||||
|
await page.getByLabel('Require passkey').getByText('Require passkey').click();
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set email options.
|
||||||
|
await page.getByRole('button', { name: 'Email Options' }).click();
|
||||||
|
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
|
||||||
|
await page.getByLabel('Message (Optional)').fill('MESSAGE');
|
||||||
|
|
||||||
|
// Set advanced options.
|
||||||
|
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||||
|
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||||
|
await page.getByLabel('DD/MM/YYYY').click();
|
||||||
|
|
||||||
|
await page.locator('.time-zone-field').click();
|
||||||
|
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||||
|
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add 2 signers.
|
||||||
|
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
|
||||||
|
await page.getByPlaceholder('Name').fill('Recipient 1');
|
||||||
|
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||||
|
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
|
||||||
|
|
||||||
|
// Apply require passkey for Recipient 1.
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
await page.getByLabel('Show advanced settings').check();
|
||||||
|
await page.getByRole('combobox').first().click();
|
||||||
|
await page.getByLabel('Require passkey').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save template' }).click();
|
||||||
|
|
||||||
|
// Use template
|
||||||
|
await page.waitForURL(`/t/${team.url}/templates`);
|
||||||
|
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Create as draft' }).click();
|
||||||
|
|
||||||
|
// Review that the document was created with the correct values.
|
||||||
|
await page.waitForURL(/documents/);
|
||||||
|
|
||||||
|
const documentId = Number(page.url().split('/').pop());
|
||||||
|
|
||||||
|
const document = await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: true,
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.teamId).toEqual(team.id);
|
||||||
|
|
||||||
|
const documentAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||||
|
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
|
||||||
|
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
|
||||||
|
isBillingEnabled ? 'PASSKEY' : null,
|
||||||
|
);
|
||||||
|
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||||
|
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||||
|
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||||
|
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||||
|
expect(document.documentMeta?.timezone).toEqual('Etc/UTC');
|
||||||
|
|
||||||
|
const recipientOne = document.Recipient[0];
|
||||||
|
const recipientTwo = document.Recipient[1];
|
||||||
|
|
||||||
|
const recipientOneAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipientOne.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientTwoAuth = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipientTwo.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||||
|
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
|
||||||
|
});
|
||||||
BIN
packages/assets/fonts/noto-sans.ttf
Normal file
BIN
packages/assets/fonts/noto-sans.ttf
Normal file
Binary file not shown.
@ -1,6 +1,6 @@
|
|||||||
import { APP_BASE_URL } from './app';
|
import { APP_BASE_URL } from './app';
|
||||||
|
|
||||||
export const DEFAULT_STANDARD_FONT_SIZE = 15;
|
export const DEFAULT_STANDARD_FONT_SIZE = 12;
|
||||||
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
||||||
|
|
||||||
export const MIN_STANDARD_FONT_SIZE = 8;
|
export const MIN_STANDARD_FONT_SIZE = 8;
|
||||||
|
|||||||
12
packages/lib/schemas/common.ts
Normal file
12
packages/lib/schemas/common.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { URL_REGEX } from '../constants/url-regex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note this allows empty strings.
|
||||||
|
*/
|
||||||
|
export const ZUrlSchema = z
|
||||||
|
.string()
|
||||||
|
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
|
||||||
|
message: 'Please enter a valid URL',
|
||||||
|
});
|
||||||
@ -17,6 +17,7 @@ import { getFile } from '../../universal/upload/get-file';
|
|||||||
import { putPdfFile } from '../../universal/upload/put-file';
|
import { putPdfFile } from '../../universal/upload/put-file';
|
||||||
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
|
||||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||||
|
import { flattenForm } from '../pdf/flatten-form';
|
||||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||||
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
|
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
@ -101,7 +102,7 @@ export const sealDocument = async ({
|
|||||||
|
|
||||||
// Normalize and flatten layers that could cause issues with the signature
|
// Normalize and flatten layers that could cause issues with the signature
|
||||||
normalizeSignatureAppearances(doc);
|
normalizeSignatureAppearances(doc);
|
||||||
doc.getForm().flatten();
|
flattenForm(doc);
|
||||||
flattenAnnotations(doc);
|
flattenAnnotations(doc);
|
||||||
|
|
||||||
if (certificate) {
|
if (certificate) {
|
||||||
|
|||||||
@ -1,22 +1,19 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { FieldType } from '@documenso/prisma/client';
|
import type { FieldType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export type Field = {
|
|
||||||
id?: number | null;
|
|
||||||
type: FieldType;
|
|
||||||
signerEmail: string;
|
|
||||||
signerId?: number;
|
|
||||||
pageNumber: number;
|
|
||||||
pageX: number;
|
|
||||||
pageY: number;
|
|
||||||
pageWidth: number;
|
|
||||||
pageHeight: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SetFieldsForTemplateOptions = {
|
export type SetFieldsForTemplateOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
templateId: number;
|
templateId: number;
|
||||||
fields: Field[];
|
fields: {
|
||||||
|
id?: number | null;
|
||||||
|
type: FieldType;
|
||||||
|
signerEmail: string;
|
||||||
|
pageNumber: number;
|
||||||
|
pageX: number;
|
||||||
|
pageY: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setFieldsForTemplate = async ({
|
export const setFieldsForTemplate = async ({
|
||||||
@ -58,11 +55,7 @@ export const setFieldsForTemplate = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const removedFields = existingFields.filter(
|
const removedFields = existingFields.filter(
|
||||||
(existingField) =>
|
(existingField) => !fields.find((field) => field.id === existingField.id),
|
||||||
!fields.find(
|
|
||||||
(field) =>
|
|
||||||
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkedFields = fields.map((field) => {
|
const linkedFields = fields.map((field) => {
|
||||||
@ -127,5 +120,13 @@ export const setFieldsForTemplate = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedFields;
|
// Filter out fields that have been removed or have been updated.
|
||||||
|
const filteredFields = existingFields.filter((field) => {
|
||||||
|
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
||||||
|
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
||||||
|
|
||||||
|
return !isRemoved && !isUpdated;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...filteredFields, ...persistedFields];
|
||||||
};
|
};
|
||||||
|
|||||||
112
packages/lib/server-only/pdf/flatten-form.ts
Normal file
112
packages/lib/server-only/pdf/flatten-form.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib';
|
||||||
|
import { PDFCheckBox, PDFRadioGroup, PDFRef } from 'pdf-lib';
|
||||||
|
import {
|
||||||
|
PDFDict,
|
||||||
|
type PDFDocument,
|
||||||
|
PDFName,
|
||||||
|
drawObject,
|
||||||
|
popGraphicsState,
|
||||||
|
pushGraphicsState,
|
||||||
|
rotateInPlace,
|
||||||
|
translate,
|
||||||
|
} from 'pdf-lib';
|
||||||
|
|
||||||
|
export const flattenForm = (document: PDFDocument) => {
|
||||||
|
const form = document.getForm();
|
||||||
|
|
||||||
|
form.updateFieldAppearances();
|
||||||
|
|
||||||
|
for (const field of form.getFields()) {
|
||||||
|
for (const widget of field.acroField.getWidgets()) {
|
||||||
|
flattenWidget(document, field, widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
form.removeField(field);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageForWidget = (document: PDFDocument, widget: PDFWidgetAnnotation) => {
|
||||||
|
const pageRef = widget.P();
|
||||||
|
|
||||||
|
let page = document.getPages().find((page) => page.ref === pageRef);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
const widgetRef = document.context.getObjectRef(widget.dict);
|
||||||
|
|
||||||
|
if (!widgetRef) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
page = document.findPageForAnnotationRef(widgetRef);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAppearanceRefForWidget = (field: PDFField, widget: PDFWidgetAnnotation) => {
|
||||||
|
try {
|
||||||
|
const normalAppearance = widget.getNormalAppearance();
|
||||||
|
let normalAppearanceRef: PDFRef | null = null;
|
||||||
|
|
||||||
|
if (normalAppearance instanceof PDFRef) {
|
||||||
|
normalAppearanceRef = normalAppearance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalAppearance instanceof PDFDict &&
|
||||||
|
(field instanceof PDFCheckBox || field instanceof PDFRadioGroup)
|
||||||
|
) {
|
||||||
|
const value = field.acroField.getValue();
|
||||||
|
const ref = normalAppearance.get(value) ?? normalAppearance.get(PDFName.of('Off'));
|
||||||
|
|
||||||
|
if (ref instanceof PDFRef) {
|
||||||
|
normalAppearanceRef = ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalAppearanceRef;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidgetAnnotation) => {
|
||||||
|
try {
|
||||||
|
const page = getPageForWidget(document, widget);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appearanceRef = getAppearanceRefForWidget(field, widget);
|
||||||
|
|
||||||
|
if (!appearanceRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xObjectKey = page.node.newXObject('FlatWidget', appearanceRef);
|
||||||
|
|
||||||
|
const rectangle = widget.getRectangle();
|
||||||
|
const operators = [
|
||||||
|
pushGraphicsState(),
|
||||||
|
translate(rectangle.x, rectangle.y),
|
||||||
|
...rotateInPlace({ ...rectangle, rotation: 0 }),
|
||||||
|
drawObject(xObjectKey),
|
||||||
|
popGraphicsState(),
|
||||||
|
].filter((op) => !!op);
|
||||||
|
|
||||||
|
page.pushOperators(...operators);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,6 +1,6 @@
|
|||||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||||
import fontkit from '@pdf-lib/fontkit';
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_HANDWRITING_FONT_SIZE,
|
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||||
@ -17,6 +17,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
res.arrayBuffer(),
|
res.arrayBuffer(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fontNoto = await fetch(process.env.FONT_NOTO_SANS_URI).then(async (res) =>
|
||||||
|
res.arrayBuffer(),
|
||||||
|
);
|
||||||
|
|
||||||
const isSignatureField = isSignatureFieldType(field.type);
|
const isSignatureField = isSignatureFieldType(field.type);
|
||||||
|
|
||||||
pdf.registerFontkit(fontkit);
|
pdf.registerFontkit(fontkit);
|
||||||
@ -41,7 +45,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
||||||
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
||||||
|
|
||||||
const font = await pdf.embedFont(isSignatureField ? fontCaveat : StandardFonts.Helvetica);
|
const font = await pdf.embedFont(isSignatureField ? fontCaveat : fontNoto);
|
||||||
|
|
||||||
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
||||||
await pdf.embedFont(fontCaveat);
|
await pdf.embedFont(fontCaveat);
|
||||||
|
|||||||
@ -1,21 +1,32 @@
|
|||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { RecipientRole } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import {
|
||||||
|
type TRecipientActionAuthTypes,
|
||||||
|
ZRecipientAuthOptionsSchema,
|
||||||
|
} from '../../types/document-auth';
|
||||||
import { nanoid } from '../../universal/id';
|
import { nanoid } from '../../universal/id';
|
||||||
|
import { createRecipientAuthOptions } from '../../utils/document-auth';
|
||||||
|
|
||||||
export type SetRecipientsForTemplateOptions = {
|
export type SetRecipientsForTemplateOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
|
teamId?: number;
|
||||||
templateId: number;
|
templateId: number;
|
||||||
recipients: {
|
recipients: {
|
||||||
id?: number;
|
id?: number;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: RecipientRole;
|
role: RecipientRole;
|
||||||
|
actionAuth?: TRecipientActionAuthTypes | null;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setRecipientsForTemplate = async ({
|
export const setRecipientsForTemplate = async ({
|
||||||
userId,
|
userId,
|
||||||
|
teamId,
|
||||||
templateId,
|
templateId,
|
||||||
recipients,
|
recipients,
|
||||||
}: SetRecipientsForTemplateOptions) => {
|
}: SetRecipientsForTemplateOptions) => {
|
||||||
@ -43,6 +54,23 @@ export const setRecipientsForTemplate = async ({
|
|||||||
throw new Error('Template not found');
|
throw new Error('Template not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||||
|
|
||||||
|
// Check if user has permission to set the global action auth.
|
||||||
|
if (recipientsHaveActionAuth) {
|
||||||
|
const isDocumentEnterprise = await isUserEnterprise({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDocumentEnterprise) {
|
||||||
|
throw new AppError(
|
||||||
|
AppErrorCode.UNAUTHORIZED,
|
||||||
|
'You do not have permission to set the action auth',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedRecipients = recipients.map((recipient) => ({
|
const normalizedRecipients = recipients.map((recipient) => ({
|
||||||
...recipient,
|
...recipient,
|
||||||
email: recipient.email.toLowerCase(),
|
email: recipient.email.toLowerCase(),
|
||||||
@ -74,31 +102,59 @@ export const setRecipientsForTemplate = async ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const persistedRecipients = await prisma.$transaction(
|
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||||
// Disabling as wrapping promises here causes type issues
|
return await Promise.all(
|
||||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
linkedRecipients.map(async (recipient) => {
|
||||||
linkedRecipients.map((recipient) =>
|
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
|
||||||
prisma.recipient.upsert({
|
|
||||||
where: {
|
if (recipient.actionAuth !== undefined) {
|
||||||
id: recipient._persisted?.id ?? -1,
|
authOptions = createRecipientAuthOptions({
|
||||||
templateId,
|
accessAuth: authOptions.accessAuth,
|
||||||
},
|
actionAuth: recipient.actionAuth,
|
||||||
update: {
|
});
|
||||||
name: recipient.name,
|
}
|
||||||
email: recipient.email,
|
|
||||||
role: recipient.role,
|
const upsertedRecipient = await tx.recipient.upsert({
|
||||||
templateId,
|
where: {
|
||||||
},
|
id: recipient._persisted?.id ?? -1,
|
||||||
create: {
|
templateId,
|
||||||
name: recipient.name,
|
},
|
||||||
email: recipient.email,
|
update: {
|
||||||
role: recipient.role,
|
name: recipient.name,
|
||||||
token: nanoid(),
|
email: recipient.email,
|
||||||
templateId,
|
role: recipient.role,
|
||||||
},
|
templateId,
|
||||||
|
authOptions,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
name: recipient.name,
|
||||||
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
|
token: nanoid(),
|
||||||
|
templateId,
|
||||||
|
authOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientId = upsertedRecipient.id;
|
||||||
|
|
||||||
|
// Clear all fields if the recipient role is changed to a type that cannot have fields.
|
||||||
|
if (
|
||||||
|
recipient._persisted &&
|
||||||
|
recipient._persisted.role !== recipient.role &&
|
||||||
|
(recipient.role === RecipientRole.CC || recipient.role === RecipientRole.VIEWER)
|
||||||
|
) {
|
||||||
|
await tx.field.deleteMany({
|
||||||
|
where: {
|
||||||
|
recipientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return upsertedRecipient;
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
if (removedRecipients.length > 0) {
|
if (removedRecipients.length > 0) {
|
||||||
await prisma.recipient.deleteMany({
|
await prisma.recipient.deleteMany({
|
||||||
@ -110,5 +166,17 @@ export const setRecipientsForTemplate = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return persistedRecipients;
|
// Filter out recipients that have been removed or have been updated.
|
||||||
|
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
|
||||||
|
const isRemoved = removedRecipients.find(
|
||||||
|
(removedRecipient) => removedRecipient.id === recipient.id,
|
||||||
|
);
|
||||||
|
const isUpdated = persistedRecipients.find(
|
||||||
|
(persistedRecipient) => persistedRecipient.id === recipient.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return !isRemoved && !isUpdated;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...filteredRecipients, ...persistedRecipients];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,15 +5,25 @@ import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client';
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
|
import { ZRecipientAuthOptionsSchema } from '../../types/document-auth';
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
import {
|
||||||
|
createDocumentAuthOptions,
|
||||||
|
createRecipientAuthOptions,
|
||||||
|
extractDocumentAuthMethods,
|
||||||
|
} from '../../utils/document-auth';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
|
|
||||||
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role'> & {
|
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
|
||||||
templateRecipientId: number;
|
templateRecipientId: number;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreateDocumentFromTemplateResponse = Awaited<
|
||||||
|
ReturnType<typeof createDocumentFromTemplate>
|
||||||
|
>;
|
||||||
|
|
||||||
export type CreateDocumentFromTemplateOptions = {
|
export type CreateDocumentFromTemplateOptions = {
|
||||||
templateId: number;
|
templateId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
@ -23,6 +33,19 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
email: string;
|
email: string;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values that will override the predefined values in the template.
|
||||||
|
*/
|
||||||
|
override?: {
|
||||||
|
title?: string;
|
||||||
|
subject?: string;
|
||||||
|
message?: string;
|
||||||
|
timezone?: string;
|
||||||
|
password?: string;
|
||||||
|
dateFormat?: string;
|
||||||
|
redirectUrl?: string;
|
||||||
|
};
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,6 +54,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
recipients,
|
recipients,
|
||||||
|
override,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentFromTemplateOptions) => {
|
}: CreateDocumentFromTemplateOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@ -65,6 +89,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
templateDocumentData: true,
|
templateDocumentData: true,
|
||||||
|
templateMeta: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -72,26 +97,34 @@ export const createDocumentFromTemplate = async ({
|
|||||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
|
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recipients.length !== template.Recipient.length) {
|
// Check that all the passed in recipient IDs can be associated with a template recipient.
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid number of recipients.');
|
recipients.forEach((recipient) => {
|
||||||
}
|
const foundRecipient = template.Recipient.find(
|
||||||
|
(templateRecipient) => templateRecipient.id === recipient.id,
|
||||||
const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => {
|
);
|
||||||
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
|
|
||||||
|
|
||||||
if (!foundRecipient) {
|
if (!foundRecipient) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
AppErrorCode.INVALID_BODY,
|
AppErrorCode.INVALID_BODY,
|
||||||
`Missing template recipient with ID ${templateRecipient.id}`,
|
`Recipient with ID ${recipient.id} not found in the template.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { documentAuthOption: templateAuthOptions } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: template.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => {
|
||||||
|
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templateRecipientId: templateRecipient.id,
|
templateRecipientId: templateRecipient.id,
|
||||||
fields: templateRecipient.Field,
|
fields: templateRecipient.Field,
|
||||||
name: foundRecipient.name ?? '',
|
name: foundRecipient ? foundRecipient.name ?? '' : templateRecipient.name,
|
||||||
email: foundRecipient.email,
|
email: foundRecipient ? foundRecipient.email : templateRecipient.email,
|
||||||
role: templateRecipient.role,
|
role: templateRecipient.role,
|
||||||
|
authOptions: templateRecipient.authOptions,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -108,16 +141,38 @@ export const createDocumentFromTemplate = async ({
|
|||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
teamId: template.teamId,
|
teamId: template.teamId,
|
||||||
title: template.title,
|
title: override?.title || template.title,
|
||||||
documentDataId: documentData.id,
|
documentDataId: documentData.id,
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: templateAuthOptions.globalAccessAuth,
|
||||||
|
globalActionAuth: templateAuthOptions.globalActionAuth,
|
||||||
|
}),
|
||||||
|
documentMeta: {
|
||||||
|
create: {
|
||||||
|
subject: override?.subject || template.templateMeta?.subject,
|
||||||
|
message: override?.message || template.templateMeta?.message,
|
||||||
|
timezone: override?.timezone || template.templateMeta?.timezone,
|
||||||
|
password: override?.password || template.templateMeta?.password,
|
||||||
|
dateFormat: override?.dateFormat || template.templateMeta?.dateFormat,
|
||||||
|
redirectUrl: override?.redirectUrl || template.templateMeta?.redirectUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
Recipient: {
|
Recipient: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: finalRecipients.map((recipient) => ({
|
data: finalRecipients.map((recipient) => {
|
||||||
email: recipient.email,
|
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
|
||||||
name: recipient.name,
|
|
||||||
role: recipient.role,
|
return {
|
||||||
token: nanoid(),
|
email: recipient.email,
|
||||||
})),
|
name: recipient.name,
|
||||||
|
role: recipient.role,
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: authOptions.accessAuth,
|
||||||
|
actionAuth: authOptions.actionAuth,
|
||||||
|
}),
|
||||||
|
token: nanoid(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||||
|
|
||||||
|
export type GetTemplateWithDetailsByIdOptions = {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTemplateWithDetailsById = async ({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
}: GetTemplateWithDetailsByIdOptions): Promise<TemplateWithDetails> => {
|
||||||
|
return await prisma.template.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
team: {
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
templateDocumentData: true,
|
||||||
|
templateMeta: true,
|
||||||
|
Recipient: true,
|
||||||
|
Field: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
139
packages/lib/server-only/template/update-template-settings.ts
Normal file
139
packages/lib/server-only/template/update-template-settings.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { TemplateMeta } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||||
|
import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||||
|
|
||||||
|
export type UpdateTemplateSettingsOptions = {
|
||||||
|
userId: number;
|
||||||
|
teamId?: number;
|
||||||
|
templateId: number;
|
||||||
|
data: {
|
||||||
|
title?: string;
|
||||||
|
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||||
|
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||||
|
};
|
||||||
|
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTemplateSettings = async ({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
templateId,
|
||||||
|
meta,
|
||||||
|
data,
|
||||||
|
}: UpdateTemplateSettingsOptions) => {
|
||||||
|
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await prisma.template.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: templateId,
|
||||||
|
...(teamId
|
||||||
|
? {
|
||||||
|
team: {
|
||||||
|
id: teamId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
userId,
|
||||||
|
teamId: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
templateMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { documentAuthOption } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: template.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { templateMeta } = template;
|
||||||
|
|
||||||
|
const isDateSame = (templateMeta?.dateFormat || null) === (meta?.dateFormat || null);
|
||||||
|
const isMessageSame = (templateMeta?.message || null) === (meta?.message || null);
|
||||||
|
const isPasswordSame = (templateMeta?.password || null) === (meta?.password || null);
|
||||||
|
const isSubjectSame = (templateMeta?.subject || null) === (meta?.subject || null);
|
||||||
|
const isRedirectUrlSame = (templateMeta?.redirectUrl || null) === (meta?.redirectUrl || null);
|
||||||
|
const isTimezoneSame = (templateMeta?.timezone || null) === (meta?.timezone || null);
|
||||||
|
|
||||||
|
// Early return to avoid unnecessary updates.
|
||||||
|
if (
|
||||||
|
template.title === data.title &&
|
||||||
|
data.globalAccessAuth === documentAuthOption.globalAccessAuth &&
|
||||||
|
data.globalActionAuth === documentAuthOption.globalActionAuth &&
|
||||||
|
isDateSame &&
|
||||||
|
isMessageSame &&
|
||||||
|
isPasswordSame &&
|
||||||
|
isSubjectSame &&
|
||||||
|
isRedirectUrlSame &&
|
||||||
|
isTimezoneSame
|
||||||
|
) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
|
||||||
|
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
|
||||||
|
|
||||||
|
// If the new global auth values aren't passed in, fallback to the current document values.
|
||||||
|
const newGlobalAccessAuth =
|
||||||
|
data?.globalAccessAuth === undefined ? documentGlobalAccessAuth : data.globalAccessAuth;
|
||||||
|
const newGlobalActionAuth =
|
||||||
|
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||||
|
|
||||||
|
// Check if user has permission to set the global action auth.
|
||||||
|
if (newGlobalActionAuth) {
|
||||||
|
const isDocumentEnterprise = await isUserEnterprise({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDocumentEnterprise) {
|
||||||
|
throw new AppError(
|
||||||
|
AppErrorCode.UNAUTHORIZED,
|
||||||
|
'You do not have permission to set the action auth',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authOptions = createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: newGlobalAccessAuth,
|
||||||
|
globalActionAuth: newGlobalActionAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await prisma.template.update({
|
||||||
|
where: {
|
||||||
|
id: templateId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
authOptions,
|
||||||
|
templateMeta: {
|
||||||
|
upsert: {
|
||||||
|
where: {
|
||||||
|
templateId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Template" ADD COLUMN "authOptions" JSONB;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TemplateMeta" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"subject" TEXT,
|
||||||
|
"message" TEXT,
|
||||||
|
"timezone" TEXT DEFAULT 'Etc/UTC',
|
||||||
|
"password" TEXT,
|
||||||
|
"dateFormat" TEXT DEFAULT 'yyyy-MM-dd hh:mm a',
|
||||||
|
"templateId" INTEGER NOT NULL,
|
||||||
|
"redirectUrl" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "TemplateMeta_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TemplateMeta_templateId_key" ON "TemplateMeta"("templateId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TemplateMeta" ADD CONSTRAINT "TemplateMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -539,15 +539,29 @@ enum TemplateType {
|
|||||||
PRIVATE
|
PRIVATE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model TemplateMeta {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
subject String?
|
||||||
|
message String?
|
||||||
|
timezone String? @default("Etc/UTC") @db.Text
|
||||||
|
password String?
|
||||||
|
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||||
|
templateId Int @unique
|
||||||
|
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||||
|
redirectUrl String?
|
||||||
|
}
|
||||||
|
|
||||||
model Template {
|
model Template {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
type TemplateType @default(PRIVATE)
|
type TemplateType @default(PRIVATE)
|
||||||
title String
|
title String
|
||||||
userId Int
|
userId Int
|
||||||
teamId Int?
|
teamId Int?
|
||||||
|
authOptions Json?
|
||||||
|
templateMeta TemplateMeta?
|
||||||
templateDocumentDataId String
|
templateDocumentDataId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||||
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { prisma } from '..';
|
import { prisma } from '..';
|
||||||
|
import type { Prisma, User } from '../client';
|
||||||
import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client';
|
import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client';
|
||||||
|
|
||||||
const examplePdf = fs
|
const examplePdf = fs
|
||||||
@ -14,6 +15,32 @@ type SeedTemplateOptions = {
|
|||||||
teamId?: number;
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CreateTemplateOptions = {
|
||||||
|
key?: string | number;
|
||||||
|
createTemplateOptions?: Partial<Prisma.TemplateUncheckedCreateInput>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seedBlankTemplate = async (owner: User, options: CreateTemplateOptions = {}) => {
|
||||||
|
const { key, createTemplateOptions = {} } = options;
|
||||||
|
|
||||||
|
const documentData = await prisma.documentData.create({
|
||||||
|
data: {
|
||||||
|
type: DocumentDataType.BYTES_64,
|
||||||
|
data: examplePdf,
|
||||||
|
initialData: examplePdf,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await prisma.template.create({
|
||||||
|
data: {
|
||||||
|
title: `[TEST] Template ${key}`,
|
||||||
|
templateDocumentDataId: documentData.id,
|
||||||
|
userId: owner.id,
|
||||||
|
...createTemplateOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const seedTemplate = async (options: SeedTemplateOptions) => {
|
export const seedTemplate = async (options: SeedTemplateOptions) => {
|
||||||
const { title = 'Untitled', userId, teamId } = options;
|
const { title = 'Untitled', userId, teamId } = options;
|
||||||
|
|
||||||
|
|||||||
19
packages/prisma/types/template.ts
Normal file
19
packages/prisma/types/template.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type {
|
||||||
|
DocumentData,
|
||||||
|
Field,
|
||||||
|
Recipient,
|
||||||
|
Template,
|
||||||
|
TemplateMeta,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type TemplateWithData = Template & {
|
||||||
|
templateDocumentData?: DocumentData | null;
|
||||||
|
templateMeta?: TemplateMeta | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplateWithDetails = Template & {
|
||||||
|
templateDocumentData: DocumentData;
|
||||||
|
templateMeta: TemplateMeta | null;
|
||||||
|
Recipient: Recipient[];
|
||||||
|
Field: Field[];
|
||||||
|
};
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
|
|
||||||
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
|
import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents';
|
||||||
|
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
|
import { updateRecipient } from '@documenso/lib/server-only/admin/update-recipient';
|
||||||
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
|
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
|
||||||
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
||||||
@ -10,6 +11,7 @@ import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upse
|
|||||||
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||||
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
||||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { adminProcedure, router } from '../trpc';
|
import { adminProcedure, router } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -100,9 +102,13 @@ export const adminRouter = router({
|
|||||||
const { id } = input;
|
const { id } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await sealDocument({ documentId: id, isResealing: true });
|
const document = await getEntireDocument({ id });
|
||||||
|
|
||||||
|
const isResealing = document.status === DocumentStatus.COMPLETED;
|
||||||
|
|
||||||
|
return await sealDocument({ documentId: id, isResealing });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('resealDocument error', err);
|
console.error('resealDocument error', err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@ -123,7 +129,7 @@ export const adminRouter = router({
|
|||||||
|
|
||||||
return await deleteUser({ id });
|
return await deleteUser({ id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.error(err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@ -144,7 +150,7 @@ export const adminRouter = router({
|
|||||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.error(err);
|
||||||
|
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export const fieldRouter = router({
|
|||||||
const { templateId, fields } = input;
|
const { templateId, fields } = input;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setFieldsForTemplate({
|
return await setFieldsForTemplate({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
templateId,
|
templateId,
|
||||||
fields: fields.map((field) => ({
|
fields: fields.map((field) => ({
|
||||||
|
|||||||
@ -46,16 +46,18 @@ export const recipientRouter = router({
|
|||||||
.input(ZAddTemplateSignersMutationSchema)
|
.input(ZAddTemplateSignersMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { templateId, signers } = input;
|
const { templateId, signers, teamId } = input;
|
||||||
|
|
||||||
return await setRecipientsForTemplate({
|
return await setRecipientsForTemplate({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
teamId,
|
||||||
templateId,
|
templateId,
|
||||||
recipients: signers.map((signer) => ({
|
recipients: signers.map((signer) => ({
|
||||||
id: signer.nativeId,
|
id: signer.nativeId,
|
||||||
email: signer.email,
|
email: signer.email,
|
||||||
name: signer.name,
|
name: signer.name,
|
||||||
role: signer.role,
|
role: signer.role,
|
||||||
|
actionAuth: signer.actionAuth,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export type TAddSignersMutationSchema = z.infer<typeof ZAddSignersMutationSchema
|
|||||||
|
|
||||||
export const ZAddTemplateSignersMutationSchema = z
|
export const ZAddTemplateSignersMutationSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
teamId: z.number().optional(),
|
||||||
templateId: z.number(),
|
templateId: z.number(),
|
||||||
signers: z.array(
|
signers: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@ -41,6 +42,7 @@ export const ZAddTemplateSignersMutationSchema = z
|
|||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/
|
|||||||
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
|
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
|
||||||
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
|
||||||
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
|
import { duplicateTemplate } from '@documenso/lib/server-only/template/duplicate-template';
|
||||||
|
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||||
|
import { updateTemplateSettings } from '@documenso/lib/server-only/template/update-template-settings';
|
||||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import type { Document } from '@documenso/prisma/client';
|
import type { Document } from '@documenso/prisma/client';
|
||||||
|
|
||||||
@ -16,6 +18,8 @@ import {
|
|||||||
ZCreateTemplateMutationSchema,
|
ZCreateTemplateMutationSchema,
|
||||||
ZDeleteTemplateMutationSchema,
|
ZDeleteTemplateMutationSchema,
|
||||||
ZDuplicateTemplateMutationSchema,
|
ZDuplicateTemplateMutationSchema,
|
||||||
|
ZGetTemplateWithDetailsByIdQuerySchema,
|
||||||
|
ZUpdateTemplateSettingsMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
export const templateRouter = router({
|
export const templateRouter = router({
|
||||||
@ -123,4 +127,52 @@ export const templateRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getTemplateWithDetailsById: authenticatedProcedure
|
||||||
|
.input(ZGetTemplateWithDetailsByIdQuerySchema)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
return await getTemplateWithDetailsById({
|
||||||
|
id: input.id,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'We were unable to find this template. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Todo: Add API
|
||||||
|
updateTemplateSettings: authenticatedProcedure
|
||||||
|
.input(ZUpdateTemplateSettingsMutationSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const { templateId, teamId, data, meta } = input;
|
||||||
|
|
||||||
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
|
||||||
|
|
||||||
|
return await updateTemplateSettings({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
templateId,
|
||||||
|
data,
|
||||||
|
meta,
|
||||||
|
requestMetadata,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message:
|
||||||
|
'We were unable to update the settings for this template. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
||||||
|
import {
|
||||||
|
ZDocumentAccessAuthTypesSchema,
|
||||||
|
ZDocumentActionAuthTypesSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
|
||||||
export const ZCreateTemplateMutationSchema = z.object({
|
export const ZCreateTemplateMutationSchema = z.object({
|
||||||
title: z.string().min(1).trim(),
|
title: z.string().min(1).trim(),
|
||||||
teamId: z.number().optional(),
|
teamId: z.number().optional(),
|
||||||
@ -33,10 +39,38 @@ export const ZDeleteTemplateMutationSchema = z.object({
|
|||||||
id: z.number().min(1),
|
id: z.number().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ZUpdateTemplateSettingsMutationSchema = z.object({
|
||||||
|
templateId: z.number(),
|
||||||
|
teamId: z.number().min(1).optional(),
|
||||||
|
data: z.object({
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
|
||||||
|
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
|
||||||
|
}),
|
||||||
|
meta: z.object({
|
||||||
|
subject: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
timezone: z.string(),
|
||||||
|
dateFormat: z.string(),
|
||||||
|
redirectUrl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
|
||||||
|
message: 'Please enter a valid URL',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZGetTemplateWithDetailsByIdQuerySchema = z.object({
|
||||||
|
id: z.number().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
||||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||||
typeof ZCreateDocumentFromTemplateMutationSchema
|
typeof ZCreateDocumentFromTemplateMutationSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
|
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
|
||||||
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
|
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
|
||||||
|
export type TGetTemplateWithDetailsByIdQuerySchema = z.infer<
|
||||||
|
typeof ZGetTemplateWithDetailsByIdQuerySchema
|
||||||
|
>;
|
||||||
|
|||||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -73,6 +73,7 @@ declare namespace NodeJS {
|
|||||||
|
|
||||||
DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
|
DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
|
||||||
FONT_CAVEAT_URI: string;
|
FONT_CAVEAT_URI: string;
|
||||||
|
FONT_NOTO_SANS_URI: string;
|
||||||
|
|
||||||
POSTGRES_URL?: string;
|
POSTGRES_URL?: string;
|
||||||
DATABASE_URL?: string;
|
DATABASE_URL?: string;
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
|
||||||
|
import type { SelectProps } from '@radix-ui/react-select';
|
||||||
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
export const DocumentGlobalAuthAccessSelect = forwardRef<HTMLButtonElement, SelectProps>(
|
||||||
|
(props, ref) => (
|
||||||
|
<Select {...props}>
|
||||||
|
<SelectTrigger ref={ref} className="bg-background text-muted-foreground">
|
||||||
|
<SelectValue data-testid="documentAccessSelectValue" placeholder="None" />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{Object.values(DocumentAccessAuth).map((authType) => (
|
||||||
|
<SelectItem key={authType} value={authType}>
|
||||||
|
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||||
|
<SelectItem value={'-1'}>None</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
DocumentGlobalAuthAccessSelect.displayName = 'DocumentGlobalAuthAccessSelect';
|
||||||
|
|
||||||
|
export const DocumentGlobalAuthAccessTooltip = () => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||||
|
<h2>
|
||||||
|
<strong>Document access</strong>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>The authentication required for recipients to view the document.</p>
|
||||||
|
|
||||||
|
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
||||||
|
<li>
|
||||||
|
<strong>Require account</strong> - The recipient must be signed in to view the document
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>None</strong> - The document can be accessed directly by the URL sent to the
|
||||||
|
recipient
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
|
||||||
|
import type { SelectProps } from '@radix-ui/react-select';
|
||||||
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
|
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
|
export const DocumentGlobalAuthActionSelect = forwardRef<HTMLButtonElement, SelectProps>(
|
||||||
|
(props, ref) => (
|
||||||
|
<Select {...props}>
|
||||||
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
|
<SelectValue ref={ref} data-testid="documentActionSelectValue" placeholder="None" />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{Object.values(DocumentActionAuth)
|
||||||
|
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
||||||
|
.map((authType) => (
|
||||||
|
<SelectItem key={authType} value={authType}>
|
||||||
|
{DOCUMENT_AUTH_TYPES[authType].value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
||||||
|
<SelectItem value={'-1'}>None</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
DocumentGlobalAuthActionSelect.displayName = 'DocumentGlobalAuthActionSelect';
|
||||||
|
|
||||||
|
export const DocumentGlobalAuthActionTooltip = () => (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||||
|
<h2>
|
||||||
|
<strong>Global recipient action authentication</strong>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p>The authentication required for recipients to sign the signature field.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This can be overriden by setting the authentication requirements directly on each recipient
|
||||||
|
in the next step.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
||||||
|
{/* <li>
|
||||||
|
<strong>Require account</strong> - The recipient must be signed in
|
||||||
|
</li> */}
|
||||||
|
<li>
|
||||||
|
<strong>Require passkey</strong> - The recipient must have an account and passkey
|
||||||
|
configured via their settings
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled via
|
||||||
|
their settings
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>None</strong> - No authentication required
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const DocumentSendEmailMessageHelper = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
You can use the following variables in your message:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm">
|
||||||
|
<li className="text-muted-foreground">
|
||||||
|
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
||||||
|
{'{signer.name}'}
|
||||||
|
</code>{' '}
|
||||||
|
- The signer's name
|
||||||
|
</li>
|
||||||
|
<li className="text-muted-foreground">
|
||||||
|
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
||||||
|
{'{signer.email}'}
|
||||||
|
</code>{' '}
|
||||||
|
- The signer's email
|
||||||
|
</li>
|
||||||
|
<li className="text-muted-foreground">
|
||||||
|
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
||||||
|
{'{document.name}'}
|
||||||
|
</code>{' '}
|
||||||
|
- The document's name
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { forwardRef } from 'react';
|
||||||
|
|
||||||
import type { SelectProps } from '@radix-ui/react-select';
|
import type { SelectProps } from '@radix-ui/react-select';
|
||||||
import { InfoIcon } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
@ -12,86 +12,86 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
|||||||
|
|
||||||
export type RecipientRoleSelectProps = SelectProps;
|
export type RecipientRoleSelectProps = SelectProps;
|
||||||
|
|
||||||
export const RecipientRoleSelect = (props: RecipientRoleSelectProps) => {
|
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, SelectProps>((props, ref) => (
|
||||||
return (
|
<Select {...props}>
|
||||||
<Select {...props}>
|
<SelectTrigger ref={ref} className="bg-background w-[60px]">
|
||||||
<SelectTrigger className="bg-background w-[60px]">
|
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
|
||||||
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
|
{ROLE_ICONS[props.value as RecipientRole]}
|
||||||
{ROLE_ICONS[props.value as RecipientRole]}
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent align="end">
|
<SelectContent align="end">
|
||||||
<SelectItem value={RecipientRole.SIGNER}>
|
<SelectItem value={RecipientRole.SIGNER}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex w-[150px] items-center">
|
<div className="flex w-[150px] items-center">
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
|
||||||
Needs to sign
|
Needs to sign
|
||||||
</div>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
|
||||||
<p>The recipient is required to sign the document for it to be completed.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||||
|
<p>The recipient is required to sign the document for it to be completed.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.APPROVER}>
|
<SelectItem value={RecipientRole.APPROVER}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex w-[150px] items-center">
|
<div className="flex w-[150px] items-center">
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
|
||||||
Needs to approve
|
Needs to approve
|
||||||
</div>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
|
||||||
<p>The recipient is required to approve the document for it to be completed.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||||
|
<p>The recipient is required to approve the document for it to be completed.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.VIEWER}>
|
<SelectItem value={RecipientRole.VIEWER}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex w-[150px] items-center">
|
<div className="flex w-[150px] items-center">
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
|
||||||
Needs to view
|
Needs to view
|
||||||
</div>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
|
||||||
<p>The recipient is required to view the document for it to be completed.</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||||
|
<p>The recipient is required to view the document for it to be completed.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
<SelectItem value={RecipientRole.CC}>
|
<SelectItem value={RecipientRole.CC}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex w-[150px] items-center">
|
<div className="flex w-[150px] items-center">
|
||||||
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
|
||||||
Receives copy
|
Receives copy
|
||||||
</div>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
|
||||||
<p>
|
|
||||||
The recipient is not required to take any action and receives a copy of the
|
|
||||||
document after it is completed.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
<Tooltip>
|
||||||
</SelectContent>
|
<TooltipTrigger>
|
||||||
</Select>
|
<InfoIcon className="h-4 w-4" />
|
||||||
);
|
</TooltipTrigger>
|
||||||
};
|
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||||
|
<p>
|
||||||
|
The recipient is not required to take any action and receives a copy of the document
|
||||||
|
after it is completed.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
));
|
||||||
|
|
||||||
|
RecipientRoleSelect.displayName = 'RecipientRoleSelect';
|
||||||
|
|||||||
@ -63,15 +63,17 @@
|
|||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.2",
|
"luxon": "^3.4.2",
|
||||||
"next": "14.0.3",
|
"next": "14.0.3",
|
||||||
"pdfjs-dist": "3.6.172",
|
"pdfjs-dist": "3.11.174",
|
||||||
|
"react": "18.2.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-day-picker": "^8.7.1",
|
"react-day-picker": "^8.7.1",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "^7.45.4",
|
"react-hook-form": "^7.45.4",
|
||||||
"react-pdf": "7.3.3",
|
"react-pdf": "7.7.3",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"tailwind-merge": "^1.12.0",
|
"tailwind-merge": "^1.12.0",
|
||||||
"tailwindcss-animate": "^1.0.5",
|
"tailwindcss-animate": "^1.0.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7,16 +7,18 @@ import { InfoIcon } from 'lucide-react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
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';
|
||||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import {
|
|
||||||
DocumentAccessAuth,
|
|
||||||
DocumentActionAuth,
|
|
||||||
DocumentAuth,
|
|
||||||
} from '@documenso/lib/types/document-auth';
|
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
import {
|
||||||
|
DocumentGlobalAuthAccessSelect,
|
||||||
|
DocumentGlobalAuthAccessTooltip,
|
||||||
|
} from '@documenso/ui/components/document/document-global-auth-access-select';
|
||||||
|
import {
|
||||||
|
DocumentGlobalAuthActionSelect,
|
||||||
|
DocumentGlobalAuthActionTooltip,
|
||||||
|
} from '@documenso/ui/components/document/document-global-auth-action-select';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@ -144,49 +146,11 @@ export const AddSettingsFormPartial = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="flex flex-row items-center">
|
<FormLabel className="flex flex-row items-center">
|
||||||
Document access
|
Document access
|
||||||
<Tooltip>
|
<DocumentGlobalAuthAccessTooltip />
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
|
||||||
<h2>
|
|
||||||
<strong>Document access</strong>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p>The authentication required for recipients to view the document.</p>
|
|
||||||
|
|
||||||
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
|
||||||
<li>
|
|
||||||
<strong>Require account</strong> - The recipient must be signed in to
|
|
||||||
view the document
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>None</strong> - The document can be accessed directly by the URL
|
|
||||||
sent to the recipient
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
|
||||||
<SelectValue data-testid="documentAccessSelectValue" placeholder="None" />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{Object.values(DocumentAccessAuth).map((authType) => (
|
|
||||||
<SelectItem key={authType} value={authType}>
|
|
||||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
|
||||||
<SelectItem value={'-1'}>None</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -200,64 +164,11 @@ export const AddSettingsFormPartial = ({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="flex flex-row items-center">
|
<FormLabel className="flex flex-row items-center">
|
||||||
Recipient action authentication
|
Recipient action authentication
|
||||||
<Tooltip>
|
<DocumentGlobalAuthActionTooltip />
|
||||||
<TooltipTrigger>
|
|
||||||
<InfoIcon className="mx-2 h-4 w-4" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
|
|
||||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
|
||||||
<h2>
|
|
||||||
<strong>Global recipient action authentication</strong>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
The authentication required for recipients to sign the signature field.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
This can be overriden by setting the authentication requirements
|
|
||||||
directly on each recipient in the next step.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
|
|
||||||
{/* <li>
|
|
||||||
<strong>Require account</strong> - The recipient must be signed in
|
|
||||||
</li> */}
|
|
||||||
<li>
|
|
||||||
<strong>Require passkey</strong> - The recipient must have an account
|
|
||||||
and passkey configured via their settings
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Require 2FA</strong> - The recipient must have an account and
|
|
||||||
2FA enabled via their settings
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>None</strong> - No authentication required
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
|
||||||
<SelectValue data-testid="documentActionSelectValue" placeholder="None" />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{Object.values(DocumentActionAuth)
|
|
||||||
.filter((auth) => auth !== DocumentAuth.ACCOUNT)
|
|
||||||
.map((authType) => (
|
|
||||||
<SelectItem key={authType} value={authType}>
|
|
||||||
{DOCUMENT_AUTH_TYPES[authType].value}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Note: -1 is remapped in the Zod schema to the required value. */}
|
|
||||||
<SelectItem value={'-1'}>None</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
|
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||||
|
|
||||||
import { FormErrorMessage } from '../form/form-error-message';
|
import { FormErrorMessage } from '../form/form-error-message';
|
||||||
import { Input } from '../input';
|
import { Input } from '../input';
|
||||||
@ -104,32 +105,7 @@ export const AddSubjectFormPartial = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<DocumentSendEmailMessageHelper />
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
You can use the following variables in your message:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm">
|
|
||||||
<li className="text-muted-foreground">
|
|
||||||
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
|
||||||
{'{signer.name}'}
|
|
||||||
</code>{' '}
|
|
||||||
- The signer's name
|
|
||||||
</li>
|
|
||||||
<li className="text-muted-foreground">
|
|
||||||
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
|
||||||
{'{signer.email}'}
|
|
||||||
</code>{' '}
|
|
||||||
- The signer's email
|
|
||||||
</li>
|
|
||||||
<li className="text-muted-foreground">
|
|
||||||
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
|
|
||||||
{'{document.name}'}
|
|
||||||
</code>{' '}
|
|
||||||
- The document's name
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DocumentFlowFormContainerContent>
|
</DocumentFlowFormContainerContent>
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import {
|
|||||||
DocumentFlowFormContainerFooter,
|
DocumentFlowFormContainerFooter,
|
||||||
DocumentFlowFormContainerStep,
|
DocumentFlowFormContainerStep,
|
||||||
} from '../document-flow/document-flow-root';
|
} from '../document-flow/document-flow-root';
|
||||||
|
import { ShowFieldItem } from '../document-flow/show-field-item';
|
||||||
import type { DocumentFlowStep } from '../document-flow/types';
|
import type { DocumentFlowStep } from '../document-flow/types';
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
@ -36,15 +37,17 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
|
|||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
isTemplateOwnerEnterprise: boolean;
|
isEnterprise: boolean;
|
||||||
|
isDocumentPdfLoaded: boolean;
|
||||||
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
||||||
documentFlow,
|
documentFlow,
|
||||||
isTemplateOwnerEnterprise,
|
isEnterprise,
|
||||||
recipients,
|
recipients,
|
||||||
fields: _fields,
|
fields,
|
||||||
|
isDocumentPdfLoaded,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
}: AddTemplatePlaceholderRecipientsFormProps) => {
|
||||||
const initialId = useId();
|
const initialId = useId();
|
||||||
@ -144,6 +147,11 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DocumentFlowFormContainerContent>
|
<DocumentFlowFormContainerContent>
|
||||||
|
{isDocumentPdfLoaded &&
|
||||||
|
fields.map((field, index) => (
|
||||||
|
<ShowFieldItem key={index} field={field} recipients={recipients} />
|
||||||
|
))}
|
||||||
|
|
||||||
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<div className="flex w-full flex-col gap-y-2">
|
<div className="flex w-full flex-col gap-y-2">
|
||||||
@ -209,7 +217,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showAdvancedSettings && isTemplateOwnerEnterprise && (
|
{showAdvancedSettings && isEnterprise && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`signers.${index}.actionAuth`}
|
name={`signers.${index}.actionAuth`}
|
||||||
@ -294,7 +302,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!alwaysShowAdvancedSettings && isTemplateOwnerEnterprise && (
|
{!alwaysShowAdvancedSettings && isEnterprise && (
|
||||||
<div className="mt-4 flex flex-row items-center">
|
<div className="mt-4 flex flex-row items-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="showAdvancedRecipientSettings"
|
id="showAdvancedRecipientSettings"
|
||||||
|
|||||||
326
packages/ui/primitives/template-flow/add-template-settings.tsx
Normal file
326
packages/ui/primitives/template-flow/add-template-settings.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { InfoIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { type Field, type Recipient } from '@documenso/prisma/client';
|
||||||
|
import type { TemplateWithData } from '@documenso/prisma/types/template';
|
||||||
|
import {
|
||||||
|
DocumentGlobalAuthAccessSelect,
|
||||||
|
DocumentGlobalAuthAccessTooltip,
|
||||||
|
} from '@documenso/ui/components/document/document-global-auth-access-select';
|
||||||
|
import {
|
||||||
|
DocumentGlobalAuthActionSelect,
|
||||||
|
DocumentGlobalAuthActionTooltip,
|
||||||
|
} from '@documenso/ui/components/document/document-global-auth-action-select';
|
||||||
|
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@documenso/ui/primitives/accordion';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
|
||||||
|
import { Combobox } from '../combobox';
|
||||||
|
import {
|
||||||
|
DocumentFlowFormContainerActions,
|
||||||
|
DocumentFlowFormContainerContent,
|
||||||
|
DocumentFlowFormContainerFooter,
|
||||||
|
DocumentFlowFormContainerStep,
|
||||||
|
} from '../document-flow/document-flow-root';
|
||||||
|
import { ShowFieldItem } from '../document-flow/show-field-item';
|
||||||
|
import type { DocumentFlowStep } from '../document-flow/types';
|
||||||
|
import { Input } from '../input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
||||||
|
import { useStep } from '../stepper';
|
||||||
|
import { Textarea } from '../textarea';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
||||||
|
import type { TAddTemplateSettingsFormSchema } from './add-template-settings.types';
|
||||||
|
import { ZAddTemplateSettingsFormSchema } from './add-template-settings.types';
|
||||||
|
|
||||||
|
export type AddTemplateSettingsFormProps = {
|
||||||
|
documentFlow: DocumentFlowStep;
|
||||||
|
recipients: Recipient[];
|
||||||
|
fields: Field[];
|
||||||
|
isEnterprise: boolean;
|
||||||
|
isDocumentPdfLoaded: boolean;
|
||||||
|
template: TemplateWithData;
|
||||||
|
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddTemplateSettingsFormPartial = ({
|
||||||
|
documentFlow,
|
||||||
|
recipients,
|
||||||
|
fields,
|
||||||
|
isEnterprise,
|
||||||
|
isDocumentPdfLoaded,
|
||||||
|
template,
|
||||||
|
onSubmit,
|
||||||
|
}: AddTemplateSettingsFormProps) => {
|
||||||
|
const { documentAuthOption } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: template.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<TAddTemplateSettingsFormSchema>({
|
||||||
|
resolver: zodResolver(ZAddTemplateSettingsFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: template.title,
|
||||||
|
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
|
||||||
|
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
|
||||||
|
meta: {
|
||||||
|
subject: template.templateMeta?.subject ?? '',
|
||||||
|
message: template.templateMeta?.message ?? '',
|
||||||
|
timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
|
||||||
|
dateFormat: template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
redirectUrl: template.templateMeta?.redirectUrl ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
|
||||||
|
// We almost always want to set the timezone to the user's local timezone to avoid confusion
|
||||||
|
// when the document is signed.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!form.formState.touchedFields.meta?.timezone) {
|
||||||
|
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||||
|
}
|
||||||
|
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DocumentFlowFormContainerContent>
|
||||||
|
{isDocumentPdfLoaded &&
|
||||||
|
fields.map((field, index) => (
|
||||||
|
<ShowFieldItem key={index} field={field} recipients={recipients} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-6"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Template title</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="globalAccessAuth"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
Document access
|
||||||
|
<DocumentGlobalAuthAccessTooltip />
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isEnterprise && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="globalActionAuth"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
Recipient action authentication
|
||||||
|
<DocumentGlobalAuthActionTooltip />
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Accordion type="multiple">
|
||||||
|
<AccordionItem value="email-options" className="border-none">
|
||||||
|
<AccordionTrigger className="text-foreground rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
|
||||||
|
Email Options
|
||||||
|
</AccordionTrigger>
|
||||||
|
|
||||||
|
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-4 text-sm leading-relaxed [&>div]:pb-0">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.subject"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Subject <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.message"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Message <span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Textarea className="bg-background h-32 resize-none" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentSendEmailMessageHelper />
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion type="multiple">
|
||||||
|
<AccordionItem value="advanced-options" className="border-none">
|
||||||
|
<AccordionTrigger className="text-foreground rounded border px-3 py-2 text-left hover:bg-neutral-200/30 hover:no-underline">
|
||||||
|
Advanced Options
|
||||||
|
</AccordionTrigger>
|
||||||
|
|
||||||
|
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-4 text-sm leading-relaxed">
|
||||||
|
<div className="flex flex-col space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.dateFormat"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Date Format</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_FORMATS.map((format) => (
|
||||||
|
<SelectItem key={format.key} value={format.value}>
|
||||||
|
{format.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.timezone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Time Zone</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Combobox
|
||||||
|
className="bg-background time-zone-field"
|
||||||
|
options={TIME_ZONES}
|
||||||
|
{...field}
|
||||||
|
onChange={(value) => value && field.onChange(value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="meta.redirectUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex flex-row items-center">
|
||||||
|
Redirect URL{' '}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<InfoIcon className="mx-2 h-4 w-4" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||||
|
Add a URL to redirect the user to once the document is signed
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</fieldset>
|
||||||
|
</Form>
|
||||||
|
</DocumentFlowFormContainerContent>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerFooter>
|
||||||
|
<DocumentFlowFormContainerStep
|
||||||
|
title={documentFlow.title}
|
||||||
|
step={currentStep}
|
||||||
|
maxStep={totalSteps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DocumentFlowFormContainerActions
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
canGoBack={stepIndex !== 0}
|
||||||
|
onGoBackClick={previousStep}
|
||||||
|
onGoNextClick={form.handleSubmit(onSubmit)}
|
||||||
|
/>
|
||||||
|
</DocumentFlowFormContainerFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
|
||||||
|
import {
|
||||||
|
ZDocumentAccessAuthTypesSchema,
|
||||||
|
ZDocumentActionAuthTypesSchema,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
|
||||||
|
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
|
||||||
|
|
||||||
|
export const ZAddTemplateSettingsFormSchema = z.object({
|
||||||
|
title: z.string().trim().min(1, { message: "Title can't be empty" }),
|
||||||
|
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||||
|
ZDocumentAccessAuthTypesSchema.optional(),
|
||||||
|
),
|
||||||
|
globalActionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
|
||||||
|
ZDocumentActionAuthTypesSchema.optional(),
|
||||||
|
),
|
||||||
|
meta: z.object({
|
||||||
|
subject: z.string(),
|
||||||
|
message: z.string(),
|
||||||
|
timezone: z.string().optional().default(DEFAULT_DOCUMENT_TIME_ZONE),
|
||||||
|
dateFormat: z.string().optional().default(DEFAULT_DOCUMENT_DATE_FORMAT),
|
||||||
|
redirectUrl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
|
||||||
|
message: 'Please enter a valid URL',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TAddTemplateSettingsFormSchema = z.infer<typeof ZAddTemplateSettingsFormSchema>;
|
||||||
@ -112,6 +112,7 @@
|
|||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
"DEPLOYMENT_TARGET",
|
"DEPLOYMENT_TARGET",
|
||||||
"FONT_CAVEAT_URI",
|
"FONT_CAVEAT_URI",
|
||||||
|
"FONT_NOTO_SANS_URI",
|
||||||
"POSTGRES_URL",
|
"POSTGRES_URL",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"DATABASE_URL_UNPOOLED",
|
"DATABASE_URL_UNPOOLED",
|
||||||
|
|||||||
Reference in New Issue
Block a user