Merge branch 'main' into fix/show-teams-url-new

This commit is contained in:
Lucas Smith
2024-05-24 14:58:42 +10:00
committed by GitHub
99 changed files with 3488 additions and 57742 deletions

View File

@ -75,7 +75,7 @@ NEXT_PRIVATE_SMTP_APIKEY=
# OPTIONAL: Defines whether to force the use of TLS.
NEXT_PRIVATE_SMTP_SECURE=
# 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.
NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@documenso.com"
# OPTIONAL: The API key to use for Resend.com

View File

@ -41,7 +41,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@ -33,9 +33,9 @@ jobs:
- uses: ./.github/actions/cache-build
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3

View File

@ -33,7 +33,7 @@ jobs:
- name: Run Playwright tests
run: npm run ci
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results

View File

@ -27,7 +27,7 @@ jobs:
- name: Check Assigned User's Issue Count
id: parse-comment
uses: actions/github-script@v5
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

25
.github/workflows/issue-labeler.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Auto Label Assigned Issues
on:
issues:
types: [assigned]
jobs:
label-when-assigned:
runs-on: ubuntu-latest
steps:
- name: Label issue
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const issue = context.issue;
// To run only on issues and not on PR
if (github.context.payload.issue.pull_request === undefined) {
const labelResponse = await github.rest.issues.addLabels({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
labels: ['status: assigned']
});
}

View File

@ -17,5 +17,5 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ["needs triage"]
labels: ["status: triage"]
})

View File

@ -2,14 +2,14 @@ name: 'PR Review Reminder'
on:
pull_request:
types: ['opened', 'reopened', 'ready_for_review', 'review_requested']
types: ['opened', 'ready_for_review']
permissions:
pull-requests: write
jobs:
checkPRs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'ready_for_review')
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@v4
- uses: actions/stale@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 90

View File

@ -6,7 +6,7 @@ tasks:
set -a; source .env &&
export NEXTAUTH_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
ports:
@ -25,20 +25,10 @@ ports:
- port: 2500
visibility: private
onOpen: ignore
- port: 54320
visibility: private
- port: 54320
visibility: private
onOpen: ignore
github:
prebuilds:
master: true
pullRequests: true
pullRequestsFromForks: true
addCheck: true
addComment: true
addBadge: true
vscode:
extensions:
- aaron-bond.better-comments
@ -47,9 +37,5 @@ vscode:
- esbenp.prettier-vscode
- mikestead.dotenv
- unifiedjs.vscode-mdx
- GitHub.copilot-chat
- GitHub.copilot-labs
- GitHub.copilot
- GitHub.vscode-pull-request-github
- Prisma.prisma
- VisualStudioExptTeam.vscodeintellicode

View File

@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
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} */
const config = {
experimental: {
@ -38,6 +42,7 @@ const config = {
env: {
NEXT_PUBLIC_PROJECT: 'marketing',
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: {
'lucide-react': {

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,7 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@ -115,7 +115,7 @@ export const SinglePlayerClient = () => {
}
try {
const putFileData = await putFile(uploadedFile.file);
const putFileData = await putPdfFile(uploadedFile.file);
const documentToken = await createSinglePlayerDocument({
documentData: {
@ -248,6 +248,7 @@ export const SinglePlayerClient = () => {
recipients={uploadedFile ? [placeholderRecipient] : []}
fields={fields}
onSubmit={onFieldsSubmit}
canGoBack={true}
isDocumentPdfLoaded={true}
/>
</fieldset>

View File

@ -18,6 +18,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
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} */
const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
@ -42,6 +46,7 @@ const config = {
APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web',
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: {
'lucide-react': {

View File

@ -28,6 +28,7 @@
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"input-otp": "^1.2.4",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,8 @@
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 { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -17,9 +18,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminActionsProps = {
className?: string;
document: Document;
recipients: Recipient[];
};
export const AdminActions = ({ className, document }: AdminActionsProps) => {
export const AdminActions = ({ className, document, recipients }: AdminActionsProps) => {
const { toast } = useToast();
const { mutate: resealDocument, isLoading: isResealDocumentLoading } =
@ -47,7 +49,9 @@ export const AdminActions = ({ className, document }: AdminActionsProps) => {
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.status !== DocumentStatus.COMPLETED}
disabled={recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
)}
onClick={() => resealDocument({ id: document.id })}
>
Reseal document

View File

@ -53,7 +53,7 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
<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" />
<h2 className="text-lg font-semibold">Recipients</h2>

View File

@ -332,6 +332,7 @@ export const EditDocumentForm = ({
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit}
/>
<AddSignersFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}

View File

@ -36,11 +36,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
const { user } = await getRequiredServerComponentSession();
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
const document = await getDocumentWithDetailsById({
id: documentId,
userId: user.id,
@ -74,6 +69,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
documentMeta.password = securePassword;
}
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return (
<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">

View File

@ -3,6 +3,7 @@
import { useTransition } from 'react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@ -62,7 +63,12 @@ export const DocumentsDataTable = ({
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
cell: ({ row }) => (
<LocaleDate
date={row.original.createdAt}
format={{ ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }}
/>
),
},
{
header: 'Title',

View File

@ -10,8 +10,9 @@ import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
@ -57,7 +58,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
try {
setIsLoading(true);
const { type, data } = await putFile(file);
const { type, data } = await putPdfFile(file);
const { id: documentDataId } = await createDocumentData({
type,
@ -83,13 +84,21 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
});
router.push(`${formatDocumentsPath(team?.url)}/${id}/edit`);
} catch (error) {
console.error(error);
} catch (err) {
const error = AppError.parseError(err);
if (error instanceof TRPCClientError) {
console.error(err);
if (error.code === 'INVALID_DOCUMENT_FILE') {
toast({
title: 'Invalid file',
description: 'You cannot upload encrypted PDFs',
variant: 'destructive',
});
} else if (err instanceof TRPCClientError) {
toast({
title: 'Error',
description: error.message,
description: err.message,
variant: 'destructive',
});
} else {

View File

@ -1,10 +1,14 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
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 { cn } from '@documenso/ui/lib/utils';
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 { 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 { 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 { useOptionalCurrentTeam } from '~/providers/team';
export type EditTemplateFormProps = {
className?: string;
user: User;
template: Template;
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
initialTemplate: TemplateWithDetails;
isEnterprise: boolean;
templateRootPath: string;
};
type EditTemplateStep = 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
type EditTemplateStep = 'settings' | 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const EditTemplateForm = ({
initialTemplate,
className,
template,
recipients,
fields,
user: _user,
documentData,
isEnterprise,
templateRootPath,
}: EditTemplateFormProps) => {
const { toast } = useToast();
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> = {
settings: {
title: 'General',
description: 'Configure general settings for the template.',
stepIndex: 1,
},
signers: {
title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.',
stepIndex: 1,
stepIndex: 2,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
stepIndex: 3,
},
};
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplateSettings.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 (
data: TAddTemplatePlacholderRecipientsFormSchema,
@ -72,9 +159,11 @@ export const EditTemplateForm = ({
try {
await addTemplateSigners({
templateId: template.id,
teamId: team?.id,
signers: data.signers,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('fields');
@ -100,6 +189,9 @@ export const EditTemplateForm = ({
duration: 5000,
});
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
router.push(templateRootPath);
} catch (err) {
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 (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
@ -117,7 +218,11 @@ export const EditTemplateForm = ({
gradient
>
<CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
<LazyPDFViewer
key={templateDocumentData.id}
documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
@ -135,12 +240,25 @@ export const EditTemplateForm = ({
currentStep={currentDocumentFlow.stepIndex}
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
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplateFieldsFormPartial

View File

@ -5,10 +5,9 @@ import { redirect } from 'next/navigation';
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 { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
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 { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client';
@ -35,7 +34,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
const template = await getTemplateWithDetailsById({
id: templateId,
userId: user.id,
}).catch(() => null);
@ -44,21 +43,13 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
redirect(templateRootPath);
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
const isTemplateEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
@ -73,13 +64,10 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
isEnterprise={isTemplateEnterprise}
/>
</div>
);

View File

@ -1,21 +1,16 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { FilePlus, X } from 'lucide-react';
import { FilePlus, Loader } from 'lucide-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 { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogClose,
@ -27,24 +22,8 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
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';
const ZCreateTemplateFormSchema = z.object({
name: z.string(),
});
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
type NewTemplateDialogProps = {
teamId?: number;
templateRootPath: string;
@ -56,50 +35,20 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const { data: session } = useSession();
const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({
defaultValues: {
name: '',
},
resolver: zodResolver(ZCreateTemplateFormSchema),
});
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const [isUploadingFile, setIsUploadingFile] = useState(false);
const onFileDrop = async (file: File) => {
try {
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) {
if (isUploadingFile) {
return;
}
const file: File = uploadedFile.file;
setIsUploadingFile(true);
try {
const { type, data } = await putFile(file);
const { type, data } = await putPdfFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,
data,
@ -107,7 +56,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
const { id } = await createTemplate({
teamId,
title: values.name ? values.name : file.name,
title: file.name,
templateDocumentDataId,
});
@ -127,26 +76,16 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
description: 'Please try again later.',
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 (
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
<Dialog
open={showNewTemplateDialog}
onOpenChange={(value) => !isUploadingFile && setShowNewTemplateDialog(value)}
>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
@ -162,80 +101,23 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<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="relative">
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
<div className="mt-1.5">
{uploadedFile ? (
<Card gradient className="h-[40vh]">
<CardContent className="flex h-full flex-col items-center justify-center p-2">
<button
onClick={() => resetForm()}
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>
{isUploadingFile && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
</div>
<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">
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</div>
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
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>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isUploadingFile}>
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@ -1,14 +1,21 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { InfoIcon, Plus } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
@ -19,24 +26,59 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
}),
),
});
const ZAddRecipientsForNewDocumentSchema = z
.object({
sendDocument: z.boolean(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
}),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
@ -54,35 +96,33 @@ export function UseTemplateDialog({
const router = useRouter();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const team = useOptionalCurrentTeam();
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddRecipientsForNewDocumentSchema>({
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
recipients:
recipients.length > 0
? recipients.map((recipient) => ({
nativeId: recipient.id,
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
role: recipient.role,
}))
: [
{
name: '',
email: '',
role: RecipientRole.SIGNER,
},
],
sendDocument: false,
recipients: recipients.map((recipient) => {
const isRecipientEmailPlaceholder = recipient.email.match(
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
);
const isRecipientNamePlaceholder = recipient.name.match(
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
);
return {
id: recipient.id,
name: !isRecipientNamePlaceholder ? recipient.name : '',
email: !isRecipientEmailPlaceholder ? recipient.email : '',
};
}),
},
});
const { mutateAsync: createDocumentFromTemplate, isLoading: isCreatingDocumentFromTemplate } =
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
@ -91,6 +131,7 @@ export function UseTemplateDialog({
templateId,
teamId: team?.id,
recipients: data.recipients,
sendDocument: data.sendDocument,
});
toast({
@ -101,23 +142,35 @@ export function UseTemplateDialog({
router.push(`${documentRootPath}/${id}`);
} catch (err) {
toast({
const error = AppError.parseError(err);
const toastPayload: Toast = {
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
};
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = 'The document was created but could not be sent to recipients.';
}
toast(toastPayload);
}
};
const onCreateDocumentFromTemplate = handleSubmit(onSubmit);
const { fields: formRecipients } = useFieldArray({
control,
control: form.control,
name: 'recipients',
});
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog>
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
<Button className="cursor-pointer">
<Plus className="-ml-1 mr-2 h-4 w-4" />
@ -126,121 +179,110 @@ export function UseTemplateDialog({
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Document Recipients</DialogTitle>
<DialogDescription>Add the recipients to create the template with.</DialogDescription>
<DialogTitle>Create document from template</DialogTitle>
<DialogDescription>
{recipients.length === 0
? 'A draft document will be created'
: 'Add the recipients to create the document with'}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col space-y-4">
{formRecipients.map((recipient, index) => (
<div
key={recipient.id}
data-native-id={recipient.id}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-email`}>
Email
<span className="text-destructive ml-1 inline-block font-medium">*</span>
</Label>
<Controller
control={control}
name={`recipients.${index}.email`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-email`}
type="email"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel required>Email</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].email || 'Email'} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</div>
<div className="flex-1">
<Label htmlFor={`recipient-${recipient.id}-name`}>Name</Label>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && <FormLabel>Name</FormLabel>}
<Controller
control={control}
name={`recipients.${index}.name`}
render={({ field }) => (
<Input
id={`recipient-${recipient.id}-name`}
type="text"
className="bg-background mt-2"
disabled={isSubmitting}
{...field}
<FormControl>
<Input {...field} placeholder={recipients[index].name || 'Name'} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</div>
))}
</div>
<div className="w-[60px]">
<Controller
control={control}
name={`recipients.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
{recipients.length > 0 && (
<div className="mt-4 flex flex-row items-center">
<FormField
control={form.control}
name="sendDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="sendDocument"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={field.value}
onCheckedChange={field.onChange}
/>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="sendDocument"
>
Send document
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
The document will be immediately sent to recipients if this is
checked.
</p>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<p>Otherwise, the document will be created as a draft.</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
</div>
)}
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.recipients?.[index]?.name} />
</div>
</div>
))}
</div>
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<Button
type="button"
loading={isCreatingDocumentFromTemplate}
disabled={isCreatingDocumentFromTemplate}
onClick={onCreateDocumentFromTemplate}
>
Create Document
</Button>
</DialogFooter>
<Button type="submit" loading={form.formState.isSubmitting}>
{form.getValues('sendDocument') ? 'Create and send' : 'Create as draft'}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);

View File

@ -18,7 +18,7 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({
<FormLabel required>2FA token</FormLabel>
<FormControl>
<Input {...field} placeholder="Token" />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />

View File

@ -1,7 +1,10 @@
import { DateTime } from 'luxon';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { GetTeamTokensResponse } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
@ -23,7 +26,24 @@ export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
let tokens: GetTeamTokensResponse | null = null;
try {
tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
} catch (err) {
const error = AppError.parseError(err);
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
{match(error.code)
.with(AppErrorCode.UNAUTHORIZED, () => error.message)
.otherwise(() => 'Something went wrong.')}
</p>
</div>
);
}
return (
<div>

View File

@ -5,7 +5,7 @@ import { Button } from '@documenso/ui/primitives/button';
export default function SignatureDisclosure() {
return (
<div>
<article className="prose">
<article className="prose dark:prose-invert">
<h1>Electronic Signature Disclosure</h1>
<h2>Welcome</h2>

View File

@ -28,7 +28,7 @@ import {
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZDisable2FAForm = z.object({
@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => {
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Token" />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -30,7 +30,7 @@ import {
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list';
@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -30,7 +30,7 @@ import {
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { RecoveryCodeList } from './recovery-code-list';
@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => {
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Token" />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -38,6 +38,7 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
@ -372,9 +373,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
name="totpCode"
render={({ field }) => (
<FormItem>
<FormLabel>Authentication Token</FormLabel>
<FormLabel>Token</FormLabel>
<FormControl>
<Input type="text" {...field} />
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>

View File

@ -2,6 +2,8 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
@ -18,15 +20,29 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
error: '/signin',
},
events: {
signIn: async ({ user }) => {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
});
signIn: async ({ user: { id: userId } }) => {
const [user] = await Promise.all([
await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
}),
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress,
userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
}),
]);
// Create the Stripe customer and attach it to the user if it doesn't exist.
if (user.customerId === null && IS_BILLING_ENABLED()) {
await getStripeCustomerByUser(user).catch((err) => {
console.error(err);
});
}
},
signOut: async ({ token }) => {
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;

View File

@ -41,7 +41,7 @@ volumes:
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.

View File

@ -58,7 +58,7 @@ services:
- NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=${NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- 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:
- ${PORT:-3000}:${PORT:-3000}
volumes:

118
package-lock.json generated
View File

@ -109,6 +109,7 @@
"cookie-es": "^1.0.0",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"input-otp": "^1.2.4",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
@ -13767,6 +13768,15 @@
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="
},
"node_modules/input-otp": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz",
"integrity": "sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==",
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0",
"react-dom": "^16.8 || ^17.0 || ^18.0"
}
},
"node_modules/internal-slot": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz",
@ -17536,6 +17546,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
"optional": true,
"engines": {
"node": ">=8"
}
@ -17580,18 +17591,15 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
},
"node_modules/pdfjs-dist": {
"version": "3.6.172",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.6.172.tgz",
"integrity": "sha512-bfOhCg+S9DXh/ImWhWYTOiq3aVMFSCvzGiBzsIJtdMC71kVWDBw7UXr32xh0y56qc5wMVylIeqV3hBaRsu+e+w==",
"dependencies": {
"path2d-polyfill": "^2.0.1",
"web-streams-polyfill": "^3.2.1"
},
"version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
"engines": {
"node": ">=16"
"node": ">=18"
},
"optionalDependencies": {
"canvas": "^2.11.2"
"canvas": "^2.11.2",
"path2d-polyfill": "^2.0.1"
}
},
"node_modules/peberminta": {
@ -19011,42 +19019,6 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz",
@ -21357,11 +21329,6 @@
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
"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": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz",
@ -22986,6 +22953,14 @@
"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": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
@ -25390,11 +25365,13 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.2",
"next": "14.0.3",
"pdfjs-dist": "3.6.172",
"pdfjs-dist": "3.11.174",
"react": "18.2.0",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.7.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.4",
"react-pdf": "7.3.3",
"react-pdf": "7.7.3",
"react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5",
@ -25411,6 +25388,43 @@
"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": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",

View File

@ -12,6 +12,8 @@ import {
ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema,
ZDownloadDocumentSuccessfulSchema,
ZGenerateDocumentFromTemplateMutationResponseSchema,
ZGenerateDocumentFromTemplateMutationSchema,
ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema,
@ -85,6 +87,24 @@ export const ApiContractV1 = c.router(
404: ZUnsuccessfulResponseSchema,
},
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: {

View File

@ -1,6 +1,7 @@
import { createNextRoute } from '@ts-rest/next';
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 { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
@ -19,10 +20,12 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
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 { 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 { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import {
getPresignGetUrl,
getPresignPostUrl,
@ -229,6 +232,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
requestMetadata: extractNextApiRequestMetadata(args.req),
});
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
const recipients = await setRecipientsForDocument({
userId: user.id,
teamId: team?.id,
@ -279,7 +289,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const document = await createDocumentFromTemplate({
const document = await createDocumentFromTemplateLegacy({
templateId,
userId: user.id,
teamId: team?.id,
@ -296,7 +306,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
formValues: body.formValues,
});
const newDocumentData = await putFile({
const newDocumentData = await putPdfFile({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@ -324,10 +334,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
subject: body.meta.subject,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
...body.meta,
requestMetadata: extractNextApiRequestMetadata(args.req),
});
}
@ -347,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) => {
const { id } = args.params;

View File

@ -1,5 +1,6 @@
import { z } from 'zod';
import { ZUrlSchema } from '@documenso/lib/schemas/common';
import {
FieldType,
ReadStatus,
@ -141,6 +142,59 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
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({
name: z.string().min(1),
email: z.string().email().min(1),

View File

@ -41,8 +41,8 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
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();
@ -52,11 +52,7 @@ test.describe('[EE_ONLY]', () => {
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// 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 expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
await unseedUser(user.id);
});
@ -89,8 +85,8 @@ test.describe('[EE_ONLY]', () => {
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
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();
@ -168,11 +164,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// 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 expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});

View File

@ -48,7 +48,7 @@ test.describe('[EE_ONLY]', () => {
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').click();
await page.getByLabel('Show advanced settings').check();
// Navigate to the next step and back.
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 }) => {
const user = await seedUser();
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 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);
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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');
});

View File

@ -189,7 +189,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
@ -200,7 +207,14 @@ test('[TEMPLATES]: use template', async ({ page }) => {
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create Document' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);

Binary file not shown.

View File

@ -5,7 +5,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { alphaid, nanoid } from '@documenso/lib/universal/id';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
@ -74,7 +74,7 @@ export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersChecko
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
).then(async (res) => res.arrayBuffer());
const { id: documentDataId } = await putFile({
const { id: documentDataId } = await putPdfFile({
name: 'Documenso Supporter Pledge.pdf',
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(documentBuffer),

View File

@ -21,6 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_allow_encrypted_documents: false,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_document_page_view_history_sheet: false,
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.

View File

@ -1,6 +1,6 @@
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 MIN_STANDARD_FONT_SIZE = 8;

View File

@ -0,0 +1,2 @@
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;

View File

@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client';
@ -149,4 +150,24 @@ export class AppError extends Error {
return null;
}
}
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.otherwise(() => 500 as const);
return {
status,
body: {
message: status !== 500 ? error.message : 'Something went wrong',
},
};
}
}

View 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',
});

View File

@ -137,7 +137,7 @@ export const completeDocumentWithToken = async ({
await sendPendingEmail({ documentId, recipientId: recipient.id });
}
const documents = await prisma.document.updateMany({
const haveAllRecipientsSigned = await prisma.document.findFirst({
where: {
id: document.id,
Recipient: {
@ -146,13 +146,9 @@ export const completeDocumentWithToken = async ({
},
},
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
if (documents.count > 0) {
if (haveAllRecipientsSigned) {
await sealDocument({ documentId: document.id, requestMetadata });
}

View File

@ -75,18 +75,20 @@ export const deleteDocument = async ({
}
// Continue to hide the document from the user if they are a recipient.
// Dirty way of doing this but it's faster than refetching the document.
if (userRecipient?.documentDeletedAt === null) {
await prisma.recipient.update({
where: {
documentId_email: {
documentId: document.id,
email: user.email,
await prisma.recipient
.update({
where: {
id: userRecipient.id,
},
},
data: {
documentDeletedAt: new Date().toISOString(),
},
});
data: {
documentDeletedAt: new Date().toISOString(),
},
})
.catch(() => {
// Do nothing.
});
}
// Return partial document for API v1 response.

View File

@ -110,7 +110,7 @@ export const resendDocument = async ({
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role,
@ -135,7 +135,7 @@ export const resendDocument = async ({
address: FROM_ADDRESS,
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
? renderCustomEmailTemplate(`Reminder: ${customEmail.subject}`, customEmailTemplate)
: emailSubject,
html: render(template),
text: render(template, { plainText: true }),

View File

@ -14,9 +14,10 @@ import { signPdf } from '@documenso/signing';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { putPdfFile } from '../../universal/upload/put-file';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
import { flattenForm } from '../pdf/flatten-form';
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -40,6 +41,11 @@ export const sealDocument = async ({
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
Recipient: {
every: {
signingStatus: SigningStatus.SIGNED,
},
},
},
include: {
documentData: true,
@ -53,10 +59,6 @@ export const sealDocument = async ({
throw new Error(`Document ${document.id} has no document data`);
}
if (document.status !== DocumentStatus.COMPLETED) {
throw new Error(`Document ${document.id} has not been completed`);
}
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
@ -92,22 +94,24 @@ export const sealDocument = async ({
// !: Need to write the fields onto the document as a hard copy
const pdfData = await getFile(documentData);
const certificate = await getCertificatePdf({ documentId }).then(async (doc) =>
PDFDocument.load(doc),
);
const certificate = await getCertificatePdf({ documentId })
.then(async (doc) => PDFDocument.load(doc))
.catch(() => null);
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
normalizeSignatureAppearances(doc);
doc.getForm().flatten();
flattenForm(doc);
flattenAnnotations(doc);
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
if (certificate) {
const certificatePages = await doc.copyPages(certificate, certificate.getPageIndices());
certificatePages.forEach((page) => {
doc.addPage(page);
});
certificatePages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
await insertFieldInPDF(doc, field);
@ -119,7 +123,7 @@ export const sealDocument = async ({
const { name, ext } = path.parse(document.title);
const { data: newData } = await putFile({
const { data: newData } = await putPdfFile({
name: `${name}_signed${ext}`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdfBuffer),
@ -138,6 +142,16 @@ export const sealDocument = async ({
}
await prisma.$transaction(async (tx) => {
await tx.document.update({
where: {
id: document.id,
},
data: {
status: DocumentStatus.COMPLETED,
completedAt: new Date(),
},
});
await tx.documentData.update({
where: {
id: documentData.id,

View File

@ -8,6 +8,7 @@ import { sealDocument } from '@documenso/lib/server-only/document/seal-document'
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma';
@ -20,7 +21,6 @@ import {
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
import { getFile } from '../../universal/upload/get-file';
import { putFile } from '../../universal/upload/put-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -102,7 +102,7 @@ export const sendDocument = async ({
formValues: document.formValues as Record<string, string | number | boolean>,
});
const newDocumentData = await putFile({
const newDocumentData = await putPdfFile({
name: document.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@ -151,7 +151,7 @@ export const sendDocument = async ({
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(
selfSigner ? selfSignerCustomEmail : customEmail?.message || '',
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
customEmailTemplate,
),
role: recipient.role,

View File

@ -1,22 +1,19 @@
import { prisma } from '@documenso/prisma';
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 = {
userId: 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 ({
@ -58,11 +55,7 @@ export const setFieldsForTemplate = async ({
});
const removedFields = existingFields.filter(
(existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
(existingField) => !fields.find((field) => field.id === existingField.id),
);
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];
};

View File

@ -35,6 +35,7 @@ export const getCertificatePdf = async ({ documentId }: GetCertificatePdfOptions
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
const result = await page.pdf({

View 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);
}
};

View File

@ -1,6 +1,6 @@
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
import fontkit from '@pdf-lib/fontkit';
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { PDFDocument } from 'pdf-lib';
import {
DEFAULT_HANDWRITING_FONT_SIZE,
@ -17,6 +17,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
res.arrayBuffer(),
);
const fontNoto = await fetch(process.env.FONT_NOTO_SANS_URI).then(async (res) =>
res.arrayBuffer(),
);
const isSignatureField = isSignatureFieldType(field.type);
pdf.registerFontkit(fontkit);
@ -41,7 +45,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const fieldX = pageWidth * (Number(field.positionX) / 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) {
await pdf.embedFont(fontCaveat);

View File

@ -1,3 +1,4 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
@ -6,6 +7,8 @@ export type GetUserTokensOptions = {
teamId: number;
};
export type GetTeamTokensResponse = Awaited<ReturnType<typeof getTeamTokens>>;
export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => {
const teamMember = await prisma.teamMember.findFirst({
where: {
@ -15,7 +18,10 @@ export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) =>
});
if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to view tokens for this team');
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have the required permissions to view this page.',
);
}
return await prisma.apiToken.findMany({

View File

@ -1,21 +1,32 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
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 { createRecipientAuthOptions } from '../../utils/document-auth';
export type SetRecipientsForTemplateOptions = {
userId: number;
teamId?: number;
templateId: number;
recipients: {
id?: number;
email: string;
name: string;
role: RecipientRole;
actionAuth?: TRecipientActionAuthTypes | null;
}[];
};
export const setRecipientsForTemplate = async ({
userId,
teamId,
templateId,
recipients,
}: SetRecipientsForTemplateOptions) => {
@ -43,6 +54,23 @@ export const setRecipientsForTemplate = async ({
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) => ({
...recipient,
email: recipient.email.toLowerCase(),
@ -74,31 +102,59 @@ export const setRecipientsForTemplate = async ({
};
});
const persistedRecipients = await prisma.$transaction(
// Disabling as wrapping promises here causes type issues
// eslint-disable-next-line @typescript-eslint/promise-function-async
linkedRecipients.map((recipient) =>
prisma.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
templateId,
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
templateId,
},
create: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
token: nanoid(),
templateId,
},
const persistedRecipients = await prisma.$transaction(async (tx) => {
return await Promise.all(
linkedRecipients.map(async (recipient) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
if (recipient.actionAuth !== undefined) {
authOptions = createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: recipient.actionAuth,
});
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
templateId,
},
update: {
name: recipient.name,
email: recipient.email,
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) {
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];
};

View File

@ -0,0 +1,144 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
export type CreateDocumentFromTemplateLegacyOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
name?: string;
email: string;
role?: RecipientRole;
}[];
};
/**
* Legacy server function for /api/v1
*/
export const createDocumentFromTemplateLegacy = async ({
templateId,
userId,
teamId,
recipients,
}: CreateDocumentFromTemplateLegacyOptions) => {
const template = await prisma.template.findUnique({
where: {
id: templateId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
Field: true,
templateDocumentData: true,
},
});
if (!template) {
throw new Error('Template not found.');
}
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
data: template.templateDocumentData.data,
initialData: template.templateDocumentData.initialData,
},
});
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
await prisma.field.createMany({
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
if (!documentRecipient) {
throw new Error('Recipient not found.');
}
return {
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: field.customText,
inserted: field.inserted,
documentId: document.id,
recipientId: documentRecipient.id,
};
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
}),
);
}
return document;
};

View File

@ -1,16 +1,52 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
import { type Recipient, WebhookTriggerEvents } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
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 { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
templateRecipientId: number;
fields: Field[];
};
export type CreateDocumentFromTemplateResponse = Awaited<
ReturnType<typeof createDocumentFromTemplate>
>;
export type CreateDocumentFromTemplateOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients?: {
recipients: {
id: number;
name?: string;
email: string;
role?: RecipientRole;
}[];
/**
* 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;
};
export const createDocumentFromTemplate = async ({
@ -18,7 +54,15 @@ export const createDocumentFromTemplate = async ({
userId,
teamId,
recipients,
override,
requestMetadata,
}: CreateDocumentFromTemplateOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const template = await prisma.template.findUnique({
where: {
id: templateId,
@ -39,16 +83,51 @@ export const createDocumentFromTemplate = async ({
}),
},
include: {
Recipient: true,
Field: true,
Recipient: {
include: {
Field: true,
},
},
templateDocumentData: true,
templateMeta: true,
},
});
if (!template) {
throw new Error('Template not found.');
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
}
// Check that all the passed in recipient IDs can be associated with a template recipient.
recipients.forEach((recipient) => {
const foundRecipient = template.Recipient.find(
(templateRecipient) => templateRecipient.id === recipient.id,
);
if (!foundRecipient) {
throw new AppError(
AppErrorCode.INVALID_BODY,
`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 {
templateRecipientId: templateRecipient.id,
fields: templateRecipient.Field,
name: foundRecipient ? foundRecipient.name ?? '' : templateRecipient.name,
email: foundRecipient ? foundRecipient.email : templateRecipient.email,
role: templateRecipient.role,
authOptions: templateRecipient.authOptions,
};
});
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
@ -57,85 +136,104 @@ export const createDocumentFromTemplate = async ({
},
});
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {
create: template.Recipient.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
})),
},
},
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
userId,
teamId: template.teamId,
title: override?.title || template.title,
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: {
createMany: {
data: finalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
include: {
Recipient: {
orderBy: {
id: 'asc',
return {
email: recipient.email,
name: recipient.name,
role: recipient.role,
authOptions: createRecipientAuthOptions({
accessAuth: authOptions.accessAuth,
actionAuth: authOptions.actionAuth,
}),
token: nanoid(),
};
}),
},
},
},
documentData: true,
},
});
include: {
Recipient: {
orderBy: {
id: 'asc',
},
},
documentData: true,
},
});
await prisma.field.createMany({
data: template.Field.map((field) => {
const recipient = template.Recipient.find((recipient) => recipient.id === field.recipientId);
let fieldsToCreate: Omit<Field, 'id' | 'secondaryId' | 'templateId'>[] = [];
const documentRecipient = document.Recipient.find((doc) => doc.email === recipient?.email);
Object.values(finalRecipients).forEach(({ email, fields }) => {
const recipient = document.Recipient.find((recipient) => recipient.email === email);
if (!documentRecipient) {
if (!recipient) {
throw new Error('Recipient not found.');
}
return {
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: field.customText,
inserted: field.inserted,
fieldsToCreate = fieldsToCreate.concat(
fields.map((field) => ({
documentId: document.id,
recipientId: recipient.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
})),
);
});
await tx.field.createMany({
data: fieldsToCreate,
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
recipientId: documentRecipient.id,
};
}),
});
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
user,
requestMetadata,
data: {
title: document.title,
},
}),
);
}
});
return document;
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: document,
userId,
teamId,
});
return document;
});
};

View File

@ -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,
},
});
};

View 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,
},
},
},
},
});
};

View File

@ -1,9 +1,12 @@
import { base64 } from '@scure/base';
import { env } from 'next-runtime-env';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { DocumentDataType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { createDocumentData } from '../../server-only/document-data/create-document-data';
type File = {
@ -12,14 +15,38 @@ type File = {
arrayBuffer: () => Promise<ArrayBuffer>;
};
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
export const putPdfFile = async (file: File) => {
const isEncryptedDocumentsAllowed = await getFlag('app_allow_encrypted_documents').catch(
() => false,
);
// This will prevent uploading encrypted PDFs or anything that can't be opened.
if (!isEncryptedDocumentsAllowed) {
await PDFDocument.load(await file.arrayBuffer()).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
}
const { type, data } = await putFile(file);
return await createDocumentData({ type, data });
};
/**
* Uploads a file to the appropriate storage location.
*/
export const putFile = async (file: File) => {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
return await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));
return await createDocumentData({ type, data });
};
const putFileInDatabase = async (file: File) => {

View File

@ -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;

View File

@ -539,15 +539,29 @@ enum TemplateType {
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 {
id Int @id @default(autoincrement())
type TemplateType @default(PRIVATE)
id Int @id @default(autoincrement())
type TemplateType @default(PRIVATE)
title String
userId Int
teamId Int?
authOptions Json?
templateMeta TemplateMeta?
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)

View File

@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { prisma } from '..';
import type { Prisma, User } from '../client';
import { DocumentDataType, ReadStatus, RecipientRole, SendStatus, SigningStatus } from '../client';
const examplePdf = fs
@ -14,6 +15,32 @@ type SeedTemplateOptions = {
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) => {
const { title = 'Untitled', userId, teamId } = options;

View 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[];
};

View File

@ -124,10 +124,15 @@ module.exports = {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
'caret-blink': {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'caret-blink': 'caret-blink 1.25s ease-out infinite',
},
screens: {
'3xl': '1920px',

View File

@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server';
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 { updateUser } from '@documenso/lib/server-only/admin/update-user';
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 { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { DocumentStatus } from '@documenso/prisma/client';
import { adminProcedure, router } from '../trpc';
import {
@ -100,9 +102,13 @@ export const adminRouter = router({
const { id } = input;
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) {
console.log('resealDocument error', err);
console.error('resealDocument error', err);
throw new TRPCError({
code: 'BAD_REQUEST',
@ -123,7 +129,7 @@ export const adminRouter = router({
return await deleteUser({ id });
} catch (err) {
console.log(err);
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
@ -144,7 +150,7 @@ export const adminRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.log(err);
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',

View File

@ -53,7 +53,7 @@ export const fieldRouter = router({
const { templateId, fields } = input;
try {
await setFieldsForTemplate({
return await setFieldsForTemplate({
userId: ctx.user.id,
templateId,
fields: fields.map((field) => ({

View File

@ -90,7 +90,7 @@ export const profileRouter = router({
try {
const { url } = input;
if (IS_BILLING_ENABLED() && url.length <= 6) {
if (IS_BILLING_ENABLED() && url.length < 6) {
const subscriptions = await getSubscriptionsByUserId({
userId: ctx.user.id,
}).then((subscriptions) =>

View File

@ -46,16 +46,18 @@ export const recipientRouter = router({
.input(ZAddTemplateSignersMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { templateId, signers } = input;
const { templateId, signers, teamId } = input;
return await setRecipientsForTemplate({
userId: ctx.user.id,
teamId,
templateId,
recipients: signers.map((signer) => ({
id: signer.nativeId,
email: signer.email,
name: signer.name,
role: signer.role,
actionAuth: signer.actionAuth,
})),
});
} catch (err) {

View File

@ -34,6 +34,7 @@ export type TAddSignersMutationSchema = z.infer<typeof ZAddSignersMutationSchema
export const ZAddTemplateSignersMutationSchema = z
.object({
teamId: z.number().optional(),
templateId: z.number(),
signers: z.array(
z.object({
@ -41,6 +42,7 @@ export const ZAddTemplateSignersMutationSchema = z
email: z.string().email().min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
actionAuth: ZRecipientActionAuthTypesSchema.optional().nullable(),
}),
),
})

View File

@ -10,7 +10,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentStatus,
@ -86,7 +86,7 @@ export const singleplayerRouter = router({
},
});
const { id: documentDataId } = await putFile({
const { id: documentDataId } = await putPdfFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),

View File

@ -1,10 +1,16 @@
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-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 type { Document } from '@documenso/prisma/client';
import { authenticatedProcedure, router } from '../trpc';
import {
@ -12,6 +18,8 @@ import {
ZCreateTemplateMutationSchema,
ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema,
ZGetTemplateWithDetailsByIdQuerySchema,
ZUpdateTemplateSettingsMutationSchema,
} from './schema';
export const templateRouter = router({
@ -49,19 +57,34 @@ export const templateRouter = router({
throw new Error('You have reached your document limit.');
}
return await createDocumentFromTemplate({
const requestMetadata = extractNextApiRequestMetadata(ctx.req);
let document: Document = await createDocumentFromTemplate({
templateId,
teamId,
userId: ctx.user.id,
recipients: input.recipients,
requestMetadata,
});
if (input.sendDocument) {
document = await sendDocument({
documentId: document.id,
userId: ctx.user.id,
teamId,
requestMetadata,
}).catch((err) => {
console.error(err);
throw new AppError('DOCUMENT_SEND_FAILED');
});
}
return document;
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to create this document. Please try again later.',
});
throw AppError.parseErrorToTRPCError(err);
}
}),
@ -104,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.',
});
}
}),
});

View File

@ -1,6 +1,10 @@
import { z } from 'zod';
import { RecipientRole } from '@documenso/prisma/client';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import {
ZDocumentAccessAuthTypesSchema,
ZDocumentActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(),
@ -14,12 +18,16 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
recipients: z
.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
name: z.string().optional(),
}),
)
.optional(),
.refine((recipients) => {
const emails = recipients.map((signer) => signer.email);
return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'),
sendDocument: z.boolean().optional(),
});
export const ZDuplicateTemplateMutationSchema = z.object({
@ -31,10 +39,38 @@ export const ZDeleteTemplateMutationSchema = z.object({
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 TCreateDocumentFromTemplateMutationSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationSchema
>;
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
export type TGetTemplateWithDetailsByIdQuerySchema = z.infer<
typeof ZGetTemplateWithDetailsByIdQuerySchema
>;

View File

@ -73,6 +73,7 @@ declare namespace NodeJS {
DEPLOYMENT_TARGET?: 'webapp' | 'marketing';
FONT_CAVEAT_URI: string;
FONT_NOTO_SANS_URI: string;
POSTGRES_URL?: string;
DATABASE_URL?: string;

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -0,0 +1,80 @@
'use client';
import React 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 { RecipientActionAuth } 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 type RecipientActionAuthSelectProps = SelectProps;
export const RecipientActionAuthSelect = (props: RecipientActionAuthSelectProps) => {
return (
<Select {...props}>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" />
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>Recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign fields</p>
<p className="mt-2">This will override any global settings.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Inherit authentication method</strong> - Use the global action signing
authentication method configured in the "General Settings" step
</li>
{/* <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>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@ -0,0 +1,97 @@
'use client';
import React, { forwardRef } from 'react';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { RecipientRole } from '@documenso/prisma/client';
import { ROLE_ICONS } from '@documenso/ui/primitives/recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type RecipientRoleSelectProps = SelectProps;
export const RecipientRoleSelect = forwardRef<HTMLButtonElement, SelectProps>((props, ref) => (
<Select {...props}>
<SelectTrigger ref={ref} className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[props.value as RecipientRole]}
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
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>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
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>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
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>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
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>
</SelectItem>
</SelectContent>
</Select>
));
RecipientRoleSelect.displayName = 'RecipientRoleSelect';

View File

@ -63,15 +63,17 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.2",
"next": "14.0.3",
"pdfjs-dist": "3.6.172",
"pdfjs-dist": "3.11.174",
"react": "18.2.0",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.7.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.4",
"react-pdf": "7.3.3",
"react-pdf": "7.7.3",
"react-rnd": "^10.4.1",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5",
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"
}
}
}

View File

@ -53,6 +53,7 @@ export type AddFieldsFormProps = {
recipients: Recipient[];
fields: Field[];
onSubmit: (_data: TAddFieldsFormSchema) => void;
canGoBack?: boolean;
isDocumentPdfLoaded: boolean;
};
@ -62,10 +63,13 @@ export const AddFieldsFormPartial = ({
recipients,
fields,
onSubmit,
canGoBack = false,
isDocumentPdfLoaded,
}: AddFieldsFormProps) => {
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { currentStep, totalSteps, previousStep } = useStep();
const canRenderBackButtonAsRemove =
currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack;
const {
control,
@ -595,7 +599,9 @@ export const AddFieldsFormPartial = ({
onGoBackClick={() => {
previousStep();
remove();
documentFlow.onBackStep?.();
}}
goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined}
onGoNextClick={() => void onFormSubmit()}
/>
</DocumentFlowFormContainerFooter>

View File

@ -7,16 +7,18 @@ 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 { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
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 { DocumentStatus, type Field, type Recipient, SendStatus } from '@documenso/prisma/client';
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 {
Accordion,
AccordionContent,
@ -144,49 +146,11 @@ export const AddSettingsFormPartial = ({
<FormItem>
<FormLabel className="flex flex-row items-center">
Document access
<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>
<DocumentGlobalAuthAccessTooltip />
</FormLabel>
<FormControl>
<Select {...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>
<DocumentGlobalAuthAccessSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}
@ -200,64 +164,11 @@ export const AddSettingsFormPartial = ({
<FormItem>
<FormLabel className="flex flex-row items-center">
Recipient action authentication
<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>
<DocumentGlobalAuthActionTooltip />
</FormLabel>
<FormControl>
<Select {...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>
<DocumentGlobalAuthActionSelect {...field} onValueChange={field.onChange} />
</FormControl>
</FormItem>
)}

View File

@ -4,20 +4,18 @@ import React, { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { motion } from 'framer-motion';
import { InfoIcon, Plus, Trash } from 'lucide-react';
import { Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import {
RecipientActionAuth,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
import { RecipientRole, SendStatus } from '@documenso/prisma/client';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '../button';
@ -25,10 +23,7 @@ import { Checkbox } from '../checkbox';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types';
import { ZAddSignersFormSchema } from './add-signers.types';
@ -274,67 +269,11 @@ export const AddSignersFormPartial = ({
render={({ field }) => (
<FormItem className="col-span-6">
<FormControl>
<Select
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue placeholder="Inherit authentication method" />
<Tooltip>
<TooltipTrigger className="-mr-1 ml-auto">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md p-4">
<h2>
<strong>Recipient action authentication</strong>
</h2>
<p>The authentication required for recipients to sign fields</p>
<p className="mt-2">This will override any global settings.</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<strong>Inherit authentication method</strong> - Use the
global action signing authentication method configured in
the "General Settings" step
</li>
{/* <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>
</SelectTrigger>
<SelectContent position="popper">
{/* Note: -1 is remapped in the Zod schema to the required value. */}
<SelectItem value="-1">Inherit authentication method</SelectItem>
{Object.values(RecipientActionAuth)
.filter((auth) => auth !== RecipientActionAuth.ACCOUNT)
.map((authType) => (
<SelectItem key={authType} value={authType}>
{DOCUMENT_AUTH_TYPES[authType].value}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</FormControl>
<FormMessage />
@ -348,100 +287,11 @@ export const AddSignersFormPartial = ({
render={({ field }) => (
<FormItem className="col-span-1 mt-auto">
<FormControl>
<Select
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
>
<SelectTrigger className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[field.value as RecipientRole]}
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
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>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">
{ROLE_ICONS[RecipientRole.APPROVER]}
</span>
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>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
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>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
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>
</SelectItem>
</SelectContent>
</Select>
/>
</FormControl>
<FormMessage />

View File

@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
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 { Input } from '../input';
@ -104,32 +105,7 @@ export const AddSubjectFormPartial = ({
/>
</div>
<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>
<DocumentSendEmailMessageHelper />
</div>
</div>
</DocumentFlowFormContainerContent>

View File

@ -0,0 +1,76 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Minus } from 'lucide-react';
import { cn } from '../lib/utils';
const PinInput = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
));
PinInput.displayName = 'PinInput';
const PinInputGroup = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
PinInputGroup.displayName = 'PinInputGroup';
const PinInputSlot = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const context = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = context.slots[index];
return (
<div
ref={ref}
className={cn(
'border-input relative flex h-10 w-10 items-center justify-center border-y border-r font-mono shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && 'ring-ring z-10 ring-1',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
});
PinInputSlot.displayName = 'PinInputSlot';
const PinInputSeparator = React.forwardRef<
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus className="h-5 w-5" />
</div>
));
PinInputSeparator.displayName = 'PinInputSeparator';
export { PinInput, PinInputGroup, PinInputSlot, PinInputSeparator };

View File

@ -1,31 +1,35 @@
'use client';
import React, { useId, useState } from 'react';
import React, { useId, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon, Plus, Trash } from 'lucide-react';
import { motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useFieldArray, useForm } from 'react-hook-form';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { RecipientActionAuthSelect } from '@documenso/ui/components/recipient/recipient-action-auth-select';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Checkbox } from '../checkbox';
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 { ROLE_ICONS } from '../recipient-role-icons';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '../form/form';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
import type { TAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
import { ZAddTemplatePlacholderRecipientsFormSchema } from './add-template-placeholder-recipients.types';
@ -33,30 +37,31 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isEnterprise: boolean;
isDocumentPdfLoaded: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
documentFlow,
isEnterprise,
recipients,
fields: _fields,
fields,
isDocumentPdfLoaded,
onSubmit,
}: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId();
const { data: session } = useSession();
const user = session?.user;
const [placeholderRecipientCount, setPlaceholderRecipientCount] = useState(() =>
recipients.length > 1 ? recipients.length + 1 : 2,
);
const { currentStep, totalSteps, previousStep } = useStep();
const {
control,
handleSubmit,
getValues,
formState: { errors, isSubmitting },
} = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
const form = useForm<TAddTemplatePlacholderRecipientsFormSchema>({
resolver: zodResolver(ZAddTemplatePlacholderRecipientsFormSchema),
defaultValues: {
signers:
@ -67,6 +72,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}))
: [
{
@ -74,12 +81,33 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
name: `Recipient 1`,
email: `recipient.1@documenso.com`,
role: RecipientRole.SIGNER,
actionAuth: undefined,
},
],
},
});
const onFormSubmit = handleSubmit(onSubmit);
// Always show advanced settings if any recipient has auth options.
const alwaysShowAdvancedSettings = useMemo(() => {
const recipientHasAuthOptions = recipients.find((recipient) => {
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
return recipientAuthOptions?.accessAuth || recipientAuthOptions?.actionAuth;
});
const formHasActionAuth = form.getValues('signers').find((signer) => signer.actionAuth);
return recipientHasAuthOptions !== undefined || formHasActionAuth !== undefined;
}, [recipients, form]);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
const {
formState: { errors, isSubmitting },
control,
} = form;
const onFormSubmit = form.handleSubmit(onSubmit);
const {
append: appendSigner,
@ -102,7 +130,9 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
const onAddPlaceholderRecipient = () => {
appendSigner({
formId: nanoid(12),
// Update TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX if this is ever changed.
name: `Recipient ${placeholderRecipientCount}`,
// Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed.
email: `recipient.${placeholderRecipientCount}@documenso.com`,
role: RecipientRole.SIGNER,
});
@ -117,180 +147,181 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
return (
<>
<DocumentFlowFormContainerContent>
<div className="flex w-full flex-col gap-y-4">
<AnimatePresence>
{signers.map((signer, index) => (
<motion.div
key={signer.id}
data-native-id={signer.nativeId}
className="flex flex-wrap items-end gap-x-4"
>
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-email`}>Email</Label>
{isDocumentPdfLoaded &&
fields.map((field, index) => (
<ShowFieldItem key={index} field={field} recipients={recipients} />
))}
<Input
id={`signer-${signer.id}-email`}
type="email"
value={signer.email}
disabled
className="bg-background mt-2"
/>
</div>
<AnimateGenericFadeInOut motionKey={showAdvancedSettings ? 'Show' : 'Hide'}>
<Form {...form}>
<div className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => (
<motion.fieldset
key={signer.id}
data-native-id={signer.nativeId}
disabled={isSubmitting}
className={cn('grid grid-cols-8 gap-4 pb-4', {
'border-b pt-2': showAdvancedSettings,
})}
>
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative', {
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>Email</FormLabel>
)}
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-name`}>Name</Label>
<FormControl>
<Input
type="email"
placeholder="Email"
{...field}
disabled={isSubmitting || signers[index].email === user?.email}
/>
</FormControl>
<Input
id={`signer-${signer.id}-name`}
type="text"
value={signer.name}
disabled
className="bg-background mt-2"
/>
</div>
<div className="w-[60px]">
<Controller
control={control}
name={`signers.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
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>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
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>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
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>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<div className="flex w-[150px] items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
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>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn({
'col-span-3': !showAdvancedSettings,
'col-span-4': showAdvancedSettings,
})}
>
{!showAdvancedSettings && index === 0 && <FormLabel>Name</FormLabel>}
<FormControl>
<Input
placeholder="Name"
{...field}
disabled={isSubmitting || signers[index].email === user?.email}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showAdvancedSettings && isEnterprise && (
<FormField
control={form.control}
name={`signers.${index}.actionAuth`}
render={({ field }) => (
<FormItem className="col-span-6">
<FormControl>
<RecipientActionAuthSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem className="col-span-1 mt-auto">
<FormControl>
<RecipientRoleSelect
{...field}
onValueChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
className="col-span-1 mt-auto inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSubmitting || signers.length === 1}
onClick={() => onRemoveSigner(index)}
>
<Trash className="h-5 w-5" />
</button>
</div>
</motion.fieldset>
))}
</div>
<div className="w-full">
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.email} />
<FormErrorMessage className="mt-2" error={errors.signers?.[index]?.name} />
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
<FormErrorMessage
className="mt-2"
// Dirty hack to handle errors when .root is populated for an array type
error={'signers__root' in errors && errors['signers__root']}
/>
<div
className={cn('mt-2 flex flex-row items-center space-x-4', {
'mt-4': showAdvancedSettings,
})}
>
<Button
type="button"
className="flex-1"
disabled={isSubmitting}
onClick={() => onAddPlaceholderRecipient()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Placeholder Recipient
</Button>
<div className="mt-4 flex flex-row items-center space-x-4">
<Button
type="button"
className="flex-1"
disabled={isSubmitting}
onClick={() => onAddPlaceholderRecipient()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Placeholder Recipient
</Button>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
disabled={
isSubmitting || getValues('signers').some((signer) => signer.email === user?.email)
}
onClick={() => onAddPlaceholderSelfRecipient()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Myself
</Button>
</div>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={
isSubmitting ||
form.getValues('signers').some((signer) => signer.email === user?.email)
}
onClick={() => onAddPlaceholderSelfRecipient()}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
Add Myself
</Button>
</div>
{!alwaysShowAdvancedSettings && isEnterprise && (
<div className="mt-4 flex flex-row items-center">
<Checkbox
id="showAdvancedRecipientSettings"
className="h-5 w-5"
checkClassName="dark:text-white text-primary"
checked={showAdvancedSettings}
onCheckedChange={(value) => setShowAdvancedSettings(Boolean(value))}
/>
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="showAdvancedRecipientSettings"
>
Show advanced settings
</label>
</div>
)}
</Form>
</AnimateGenericFadeInOut>
</DocumentFlowFormContainerContent>
<DocumentFlowFormContainerFooter>

View File

@ -1,5 +1,8 @@
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
import { RecipientRole } from '.prisma/client';
export const ZAddTemplatePlacholderRecipientsFormSchema = z
@ -11,6 +14,9 @@ export const ZAddTemplatePlacholderRecipientsFormSchema = z
email: z.string().min(1).email(),
name: z.string(),
role: z.nativeEnum(RecipientRole),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
})

View 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>
</>
);
};

View File

@ -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>;

View File

@ -112,6 +112,7 @@
"NODE_ENV",
"DEPLOYMENT_TARGET",
"FONT_CAVEAT_URI",
"FONT_NOTO_SANS_URI",
"POSTGRES_URL",
"DATABASE_URL",
"DATABASE_URL_UNPOOLED",