Compare commits

..

17 Commits

Author SHA1 Message Date
Adithya Krishna
ba208d9397 chore: fix conflicts and update branch
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-09 12:43:56 +05:30
Adithya Krishna
2336e70495 chore: fix linting issues
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-09 12:35:58 +05:30
Adithya Krishna
3c23624fdb chore: finalize template settings
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-09 12:35:32 +05:30
Adithya Krishna
8f9c07aa8e chore: updated triage label (#1152)
Description:

This PR updates the triage label for new issues
2024-05-08 17:56:23 +05:30
Adithya Krishna
cc4efddabf chore: updated triage label 2024-05-08 17:03:57 +05:30
Catalin Pit
2ba0f48c61 fix: unauthorized access error api tokens page team (#1134) 2024-05-08 12:03:21 +07:00
Adithya Krishna
5d5d0210fa chore: update github actions (#1085)
**Description:**

This PR updates and adds a new action to assign `status: assigned` label

---------

Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-05-08 11:52:26 +07:00
David Nguyen
e50ccca766 fix: allow template recipients to be filled (#1148)
## Description

Update the template flow to allow for entering recipient placeholder
emails and names

## Changes Made

- General refactoring
- Added advanced recipient settings for future usage
2024-05-07 17:22:24 +07:00
David Nguyen
d7a3c40050 feat: add general template enhancements (#1147)
## Description

Refactor the "use template" flow

## Changes Made

- Add placeholders for recipients
- Add audit log when document is created
- Trigger DOCUMENT_CREATED webhook when document is created
- Remove role field when using template
- Remove flaky logic when associating template recipients with form
recipients
- Refactor to use `Form` 

### Using template when document has no recipients

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/a8494ac9-0397-4e3b-a0cf-818c8454a55c">

### Using template with recipients 

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/54d949fc-ed6a-4318-bfd6-6a3179896ba9">

### Using template with the send option selected

<img width="529" alt="image"
src="https://github.com/documenso/documenso/assets/20962767/541b2664-0540-43e9-83dd-e040a45a44ea">
2024-05-07 15:04:12 +07:00
Timur Ercan
dc11676d28 fix: profile claim name length (#1144)
fixes the caim name length on the profile claim popup
2024-05-07 14:42:16 +07:00
Adithya Krishna
785b0e9085 Merge branch 'main' of https://github.com/documenso/documenso into feat/redirect-templates 2024-05-06 11:32:34 +05:30
Adithya Krishna
279e7583cc Merge branch 'main' of https://github.com/documenso/documenso into feat/redirect-templates 2024-04-30 17:43:57 +05:30
Adithya Krishna
bb15f2994a chore: revert recipient reordering
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-04-18 18:21:29 +05:30
Adithya Krishna
2d6c613e2d chore: revert filename extension change
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-04-18 18:09:59 +05:30
Adithya Krishna
f84b2fbed0 Merge branch 'main' of https://github.com/documenso/documenso into feat/redirect-templates 2024-04-18 17:49:08 +05:30
Adithya Krishna
cce0cdfbe2 feat: add template meta for templates
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-04-18 17:47:50 +05:30
Adithya Krishna
9715dbfeaa chore: fix ordering of recipients
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2024-04-17 02:24:48 +05:30
50 changed files with 1240 additions and 453 deletions

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

@@ -4,7 +4,14 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import type {
DocumentData,
Field,
Recipient,
Template,
TemplateDocumentMeta,
User,
} from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -19,6 +26,8 @@ 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 { TTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EditTemplateFormProps = {
@@ -28,11 +37,12 @@ export type EditTemplateFormProps = {
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
documentMeta: TemplateDocumentMeta | null;
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 = ({
className,
@@ -42,22 +52,28 @@ export const EditTemplateForm = ({
user: _user,
documentData,
templateRootPath,
documentMeta,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditTemplateStep>('signers');
const [step, setStep] = useState<EditTemplateStep>('settings');
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: 'Settings',
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,
},
};
@@ -65,6 +81,8 @@ export const EditTemplateForm = ({
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
const { mutateAsync: setSettingsForTemplate } =
trpc.template.setSettingsForTemplate.useMutation();
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
@@ -87,6 +105,31 @@ export const EditTemplateForm = ({
}
};
const onAddTemplateSettingsFormSubmit = async (data: TTemplateSettingsFormSchema) => {
try {
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
await setSettingsForTemplate({
templateId: template.id,
meta: {
subject,
message,
timezone,
dateFormat,
redirectUrl,
},
});
router.refresh();
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while updating the template settings.',
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
@@ -135,12 +178,22 @@ export const EditTemplateForm = ({
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplateSettingsFormPartial
template={template}
key={recipients.length}
documentFlow={documentFlow.settings}
recipients={recipients}
onSubmit={onAddTemplateSettingsFormSubmit}
documentMeta={documentMeta}
/>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit}
// Todo: Add when we setup template settings.
isTemplateOwnerEnterprise={false}
/>
<AddTemplateFieldsFormPartial

View File

@@ -44,7 +44,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
redirect(templateRootPath);
}
const { templateDocumentData } = template;
const { templateDocumentData, templateDocumentMeta } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
@@ -58,7 +58,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
]);
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,12 +73,13 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
</div>
<EditTemplateForm
className="mt-8"
className="mt-6"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
documentMeta={templateDocumentMeta}
templateRootPath={templateRootPath}
/>
</div>

View File

@@ -1,3 +1,5 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -5,7 +7,10 @@ import { InfoIcon, Plus } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod';
import { TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX } from '@documenso/lib/constants/template';
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 { trpc } from '@documenso/trpc/react';
@@ -91,6 +96,8 @@ export function UseTemplateDialog({
const router = useRouter();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const team = useOptionalCurrentTeam();
const form = useForm<TAddRecipientsForNewDocumentSchema>({
@@ -98,20 +105,18 @@ export function UseTemplateDialog({
defaultValues: {
sendDocument: false,
recipients: recipients.map((recipient) => {
const isRecipientPlaceholder = recipient.email.match(TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX);
const isRecipientEmailPlaceholder = recipient.email.match(
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
);
if (isRecipientPlaceholder) {
return {
id: recipient.id,
name: '',
email: '',
};
}
const isRecipientNamePlaceholder = recipient.name.match(
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
);
return {
id: recipient.id,
name: recipient.name,
email: recipient.email,
name: !isRecipientNamePlaceholder ? recipient.name : '',
email: !isRecipientEmailPlaceholder ? recipient.email : '',
};
}),
},
@@ -158,8 +163,14 @@ export function UseTemplateDialog({
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" />
@@ -190,7 +201,7 @@ export function UseTemplateDialog({
{index === 0 && <FormLabel required>Email</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].email} />
<Input {...field} placeholder={recipients[index].email || 'Email'} />
</FormControl>
<FormMessage />
</FormItem>
@@ -205,7 +216,7 @@ export function UseTemplateDialog({
{index === 0 && <FormLabel>Name</FormLabel>}
<FormControl>
<Input {...field} placeholder={recipients[index].name} />
<Input {...field} placeholder={recipients[index].name || 'Name'} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -131,7 +131,7 @@ export default async function CompletedSigningPage({
</div>
))
.with({ deletedAt: null }, () => (
<div className="flex items-center mt-4 text-center text-blue-600">
<div className="mt-4 flex items-center text-center text-blue-600">
<Clock8 className="mr-2 h-5 w-5" />
<span className="text-sm">Waiting for others to sign</span>
</div>

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

@@ -19,7 +19,7 @@ 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 { 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 { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@@ -286,7 +286,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,

View File

@@ -100,21 +100,13 @@ export type TCreateDocumentMutationResponseSchema = z.infer<
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
title: z.string().min(1),
recipients: z.union([
z.array(
z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email().min(1),
}),
),
z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
}),
),
]),
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
meta: z
.object({
subject: z.string(),

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { RefObject, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Field } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({

View File

@@ -9,7 +9,7 @@ import {
} from '@documenso/lib/constants/feature-flags';
import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
import { TFeatureFlagValue } from './feature-flag.types';
import type { TFeatureFlagValue } from './feature-flag.types';
export type FeatureFlagContextValue = {
getFlag: (_key: string) => TFeatureFlagValue;

View File

@@ -1 +1,2 @@
export const TEMPLATE_RECIPIENT_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
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,4 @@
import { Toast } from '@documenso/ui/primitives/use-toast';
import type { Toast } from '@documenso/ui/primitives/use-toast';
export const TOAST_DOCUMENT_SHARE_SUCCESS: Toast = {
title: 'Copied to clipboard',

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

@@ -1,3 +1,4 @@
import { Role, User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { Role } from '@documenso/prisma/client';
export const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';

View File

@@ -1,4 +1,4 @@
import { User } from '@documenso/prisma/client';
import type { User } from '@documenso/prisma/client';
import { getBackupCodes } from './get-backup-code';

View File

@@ -1,5 +1,5 @@
import { NextApiResponse } from 'next';
import { NextResponse } from 'next/server';
import type { NextApiResponse } from 'next';
import type { NextResponse } from 'next/server';
type NarrowedResponse<T> = T extends NextResponse
? NextResponse

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

@@ -0,0 +1,58 @@
'use server';
import { prisma } from '@documenso/prisma';
export type CreateTemplateDocumentMetaOptions = {
templateId: number;
subject?: string;
message?: string;
timezone?: string;
password?: string;
dateFormat?: string;
redirectUrl?: string;
};
export const upsertTemplateDocumentMeta = async ({
subject,
message,
timezone,
dateFormat,
templateId,
password,
redirectUrl,
}: CreateTemplateDocumentMetaOptions) => {
const templateDocumentMeta = await prisma.templateDocumentMeta.findFirstOrThrow({
where: {
templateId: templateId,
},
include: {
template: true,
},
});
return await prisma.$transaction(async (tx) => {
const upsertedTemplateDocumentMeta = await tx.templateDocumentMeta.upsert({
where: {
templateId,
},
update: {
subject,
message,
timezone,
password,
dateFormat,
redirectUrl,
},
create: {
templateId,
subject,
message,
timezone,
password,
dateFormat,
redirectUrl,
},
});
return upsertedTemplateDocumentMeta;
});
};

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

@@ -3,17 +3,12 @@ import { prisma } from '@documenso/prisma';
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 type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type RecipientWithId = {
id: number;
name?: string;
email: string;
};
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role'> & {
templateRecipientId: number;
fields: Field[];
@@ -23,12 +18,11 @@ export type CreateDocumentFromTemplateOptions = {
templateId: number;
userId: number;
teamId?: number;
recipients:
| RecipientWithId[]
| {
name?: string;
email: string;
}[];
recipients: {
id: number;
name?: string;
email: string;
}[];
requestMetadata?: RequestMetadata;
};
@@ -71,49 +65,36 @@ export const createDocumentFromTemplate = async ({
},
},
templateDocumentData: true,
templateDocumentMeta: true,
},
});
if (!template) {
throw new Error('Template not found.');
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
}
if (recipients.length !== template.Recipient.length) {
throw new Error('Invalid number of recipients.');
throw new AppError(AppErrorCode.INVALID_BODY, 'Invalid number of recipients.');
}
let finalRecipients: FinalRecipient[] = [];
const finalRecipients: FinalRecipient[] = template.Recipient.map((templateRecipient) => {
const foundRecipient = recipients.find((recipient) => recipient.id === templateRecipient.id);
if (recipients.length > 0 && Object.prototype.hasOwnProperty.call(recipients[0], 'id')) {
finalRecipients = template.Recipient.map((templateRecipient) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const foundRecipient = (recipients as RecipientWithId[]).find(
(recipient) => recipient.id === templateRecipient.id,
if (!foundRecipient) {
throw new AppError(
AppErrorCode.INVALID_BODY,
`Missing template recipient with ID ${templateRecipient.id}`,
);
}
if (!foundRecipient) {
throw new Error('Recipient not found.');
}
return {
templateRecipientId: templateRecipient.id,
fields: templateRecipient.Field,
name: foundRecipient.name ?? '',
email: foundRecipient.email,
role: templateRecipient.role,
};
});
} else {
// Backwards compatible logic for /v1/ API where we use the index to associate
// the provided recipient with the template recipient.
finalRecipients = recipients.map((recipient, index) => ({
templateRecipientId: template.Recipient[index].id,
fields: template.Recipient[index].Field,
name: recipient.name ?? '',
email: recipient.email,
role: template.Recipient[index].role,
}));
}
return {
templateRecipientId: templateRecipient.id,
fields: templateRecipient.Field,
name: foundRecipient.name ?? '',
email: foundRecipient.email,
role: templateRecipient.role,
};
});
const documentData = await prisma.documentData.create({
data: {
@@ -148,6 +129,15 @@ export const createDocumentFromTemplate = async ({
},
},
documentData: true,
documentMeta: {
where: {
subject: template.templateDocumentMeta?.subject,
message: template.templateDocumentMeta?.message,
dateFormat: template.templateDocumentMeta?.dateFormat,
timezone: template.templateDocumentMeta?.timezone,
redirectUrl: template.templateDocumentMeta?.redirectUrl,
},
},
},
});

View File

@@ -38,6 +38,7 @@ export const duplicateTemplate = async ({
Recipient: true,
Field: true,
templateDocumentData: true,
templateDocumentMeta: true,
},
});

View File

@@ -37,6 +37,7 @@ export const findTemplates = async ({
where: whereFilter,
include: {
templateDocumentData: true,
templateDocumentMeta: true,
team: {
select: {
id: true,

View File

@@ -29,6 +29,7 @@ export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) =>
where: whereFilter,
include: {
templateDocumentData: true,
templateDocumentMeta: true,
},
});
};

View File

@@ -1,7 +1,7 @@
import crypto from 'crypto';
import { prisma } from '@documenso/prisma';
import { TForgotPasswordFormSchema } from '@documenso/trpc/server/profile-router/schema';
import type { TForgotPasswordFormSchema } from '@documenso/trpc/server/profile-router/schema';
import { ONE_DAY, ONE_HOUR } from '../../constants/time';
import { sendForgotPassword } from '../auth/send-forgot-password';

View File

@@ -1,4 +1,4 @@
import { Field } from '@documenso/prisma/client';
import type { Field } from '@documenso/prisma/client';
/**
* Sort the fields by the Y position on the document.

View File

@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "TemplateDocumentMeta" (
"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 "TemplateDocumentMeta_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TemplateDocumentMeta_templateId_key" ON "TemplateDocumentMeta"("templateId");
-- AddForeignKey
ALTER TABLE "TemplateDocumentMeta" ADD CONSTRAINT "TemplateDocumentMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -324,6 +324,18 @@ model DocumentMeta {
redirectUrl String?
}
model TemplateDocumentMeta {
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?
}
enum ReadStatus {
NOT_OPENED
OPENED
@@ -551,6 +563,7 @@ model Template {
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
templateDocumentMeta TemplateDocumentMeta?
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[]
Field Field[]

View File

@@ -1,6 +1,6 @@
import { TRPCClientError } from '@trpc/client';
import { AppRouter } from '../server/router';
import type { AppRouter } from '../server/router';
export const isTRPCBadRequestError = (err: unknown): err is TRPCClientError<AppRouter> => {
return err instanceof TRPCClientError && err.shape?.code === 'BAD_REQUEST';

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

@@ -3,6 +3,7 @@ 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 { upsertTemplateDocumentMeta } from '@documenso/lib/server-only/template-document-meta/upsert-template-document-meta';
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';
@@ -16,6 +17,7 @@ import {
ZCreateTemplateMutationSchema,
ZDeleteTemplateMutationSchema,
ZDuplicateTemplateMutationSchema,
ZSetSettingsForTemplateMutationSchema,
} from './schema';
export const templateRouter = router({
@@ -123,4 +125,27 @@ export const templateRouter = router({
});
}
}),
setSettingsForTemplate: authenticatedProcedure
.input(ZSetSettingsForTemplateMutationSchema)
.mutation(async ({ input }) => {
try {
const { meta, templateId } = input;
return await upsertTemplateDocumentMeta({
templateId,
subject: meta.subject,
message: meta.message,
dateFormat: meta.dateFormat,
timezone: meta.timezone,
redirectUrl: meta.redirectUrl,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to set template settings. Please try again later.',
});
}
}),
});

View File

@@ -1,5 +1,7 @@
import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1).trim(),
teamId: z.number().optional(),
@@ -9,16 +11,37 @@ export const ZCreateTemplateMutationSchema = z.object({
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string().optional(),
}),
),
recipients: z
.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string().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 ZSetSettingsForTemplateMutationSchema = z.object({
templateId: z.number(),
meta: z.object({
subject: z.string().optional(),
message: z.string().optional(),
timezone: z.string().optional(),
dateFormat: z.string().optional(),
redirectUrl: z
.string()
.optional()
.refine((value) => value === undefined || value === '' || URL_REGEX.test(value), {
message: 'Please enter a valid URL',
}),
}),
});
export const ZDuplicateTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),

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 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 = (props: RecipientRoleSelectProps) => {
return (
<Select {...props}>
<SelectTrigger 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>
);
};

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

@@ -3,7 +3,8 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { VariantProps, cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';

View File

@@ -4,7 +4,8 @@ import { Eye, EyeOff } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from './button';
import { Input, InputProps } from './input';
import type { InputProps } from './input';
import { Input } from './input';
const PasswordInput = React.forwardRef<HTMLInputElement, Omit<InputProps, 'type'>>(
({ className, ...props }, ref) => {

View File

@@ -1,4 +1,4 @@
import {
import type {
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
TouchEvent as ReactTouchEvent,

View File

@@ -1,20 +1,25 @@
'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,
@@ -22,10 +27,8 @@ import {
DocumentFlowFormContainerStep,
} from '../document-flow/document-flow-root';
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 +36,29 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
fields: Field[];
isTemplateOwnerEnterprise: boolean;
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
documentFlow,
isTemplateOwnerEnterprise,
recipients,
fields: _fields,
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 +69,8 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
name: recipient.name,
email: recipient.email,
role: recipient.role,
actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
}))
: [
{
@@ -74,12 +78,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,8 +127,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_PLACEHOLDER_REGEX if this is ever changed.
// Update TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX if this is ever changed.
email: `recipient.${placeholderRecipientCount}@documenso.com`,
role: RecipientRole.SIGNER,
});
@@ -118,181 +144,176 @@ 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>
<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>
)}
<Input
id={`signer-${signer.id}-email`}
type="email"
value={signer.email}
disabled
className="bg-background mt-2"
/>
</div>
<FormControl>
<Input
type="email"
placeholder="Email"
{...field}
disabled={isSubmitting || signers[index].email === user?.email}
/>
</FormControl>
<div className="flex-1">
<Label htmlFor={`signer-${signer.id}-name`}>Name</Label>
<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 && isTemplateOwnerEnterprise && (
<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"
variant="secondary"
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 && isTemplateOwnerEnterprise && (
<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,281 @@
'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 type { Template, TemplateDocumentMeta } from '@documenso/prisma/client';
import { type Recipient, SendStatus } from '@documenso/prisma/client';
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 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 { TTemplateSettingsFormSchema } from './add-template-settings.types';
import { ZTemplateSettingsFormSchema } from './add-template-settings.types';
export type AddSettingsFormProps = {
template: Template;
documentFlow: DocumentFlowStep;
recipients: Recipient[];
documentMeta: TemplateDocumentMeta | null;
onSubmit: (_data: TTemplateSettingsFormSchema) => void;
};
export const AddTemplateSettingsFormPartial = ({
documentFlow,
recipients,
documentMeta,
template,
onSubmit,
}: AddSettingsFormProps) => {
const form = useForm<TTemplateSettingsFormSchema>({
resolver: zodResolver(ZTemplateSettingsFormSchema),
defaultValues: {
title: template.title,
meta: {
subject: documentMeta?.subject ?? '',
message: documentMeta?.message ?? '',
timezone: documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
dateFormat: documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
redirectUrl: documentMeta?.redirectUrl ?? '',
},
},
});
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
const documentHasBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
useEffect(() => {
if (!form.formState.touchedFields.meta?.timezone && !documentHasBeenSent) {
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [documentHasBeenSent, form, form.setValue, form.formState.touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerContent>
<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>Title</FormLabel>
<FormControl>
<Input className="bg-background" {...field} disabled={field.disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Accordion type="multiple" className="mt-6">
<AccordionItem value="advanced-options" className="border-none">
<AccordionTrigger className="text-foreground mb-2 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-2 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}
disabled={documentHasBeenSent}
>
<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.subject"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Subject{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add email subject
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Input
id="subject"
className="bg-background mt-2"
disabled={field.disabled}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.message"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Message{' '}
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
Add message to send in the email
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Textarea
id="message"
className="bg-background mt-2 h-24 resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="meta.timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Time Zone</FormLabel>
<FormControl>
<Combobox
className="bg-background"
options={TIME_ZONES}
{...field}
onChange={(value) => value && field.onChange(value)}
disabled={documentHasBeenSent}
/>
</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,44 @@
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';
export const ZMapNegativeOneToUndefinedSchema = z
.string()
.optional()
.transform((val) => {
if (val === '-1') {
return undefined;
}
return val;
});
export const ZTemplateSettingsFormSchema = 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().optional(),
message: z.string().optional(),
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 TTemplateSettingsFormSchema = z.infer<typeof ZTemplateSettingsFormSchema>;