Compare commits

...

5 Commits

Author SHA1 Message Date
Mythie
4e197ac24c v1.9.0-rc.7 2025-01-09 15:07:11 +11:00
Ephraim Duncan
f707e5fb10 fix: update template field schema (#1575)
Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
2025-01-09 12:06:17 +11:00
Catalin Pit
6fc5e565d0 fix: add document visibility to template (#1566)
Adds the visibility property to templates
2025-01-09 10:14:24 +11:00
Mythie
07c852744b v1.9.0-rc.6 2025-01-08 20:18:09 +11:00
Lucas Smith
4fab98c633 feat: allow switching document when using templates (#1571)
Adds the ability to upload a custom document when using a template.

This is useful when you have a given fixed template with placeholder
values that you want to decorate with Documenso fields but will then
create a final specialised document when sending it out to a given
recipient.
2025-01-07 16:13:35 +11:00
19 changed files with 865 additions and 120 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@documenso/web",
"version": "1.9.0-rc.5",
"version": "1.9.0-rc.7",
"private": true,
"license": "AGPL-3.0",
"scripts": {

View File

@@ -166,6 +166,7 @@ export const EditTemplateForm = ({
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
@@ -296,6 +297,7 @@ export const EditTemplateForm = ({
<AddTemplateSettingsFormPartial
key={recipients.length}
template={template}
currentTeamMemberRole={team?.currentTeamMember?.role}
documentFlow={documentFlow.settings}
recipients={recipients}
fields={fields}

View File

@@ -7,15 +7,17 @@ import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { InfoIcon, Plus } from 'lucide-react';
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import * as z from 'zod';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
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 { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import type { Recipient } from '@documenso/prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
@@ -50,6 +52,11 @@ import { useOptionalCurrentTeam } from '~/providers/team';
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
@@ -119,6 +126,8 @@ export function UseTemplateDialog({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
distributeDocument: false,
useCustomDocument: false,
customDocumentData: undefined,
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
@@ -145,11 +154,19 @@ export function UseTemplateDialog({
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
let customDocumentDataId: string | undefined = undefined;
if (data.useCustomDocument && data.customDocumentData) {
const customDocumentData = await putPdfFile(data.customDocumentData);
customDocumentDataId = customDocumentData.id;
}
const { id } = await createDocumentFromTemplate({
templateId,
teamId: team?.id,
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentDataId,
});
toast({
@@ -300,7 +317,6 @@ export function UseTemplateDialog({
/>
</div>
))}
</div>
{recipients.length > 0 && (
<div className="mt-4 flex flex-row items-center">
@@ -331,8 +347,8 @@ export function UseTemplateDialog({
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
The document will be immediately sent to recipients if this is
checked.
The document will be immediately sent to recipients if this
is checked.
</Trans>
</p>
@@ -358,7 +374,9 @@ export function UseTemplateDialog({
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>Create the document as pending and ready to sign.</Trans>
<Trans>
Create the document as pending and ready to sign.
</Trans>
</p>
<p>
@@ -367,8 +385,8 @@ export function UseTemplateDialog({
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send to
the recipients through your method of choice.
We will generate signing links for you, which you can send
to the recipients through your method of choice.
</Trans>
</p>
</TooltipContent>
@@ -382,7 +400,162 @@ export function UseTemplateDialog({
</div>
)}
<DialogFooter>
<FormField
control={form.control}
name="useCustomDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="useCustomDocument"
className="h-5 w-5"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue('customDocumentData', undefined);
}
}}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
Upload a custom document to use instead of the template's default
document
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
{form.watch('useCustomDocument') && (
<div className="my-4">
<FormField
control={form.control}
name="customDocumentData"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="w-full space-y-4">
<label
className={cn(
'text-muted-foreground hover:border-muted-foreground/50 group relative flex min-h-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 transition-colors',
{
'border-destructive hover:border-destructive':
form.formState.errors.customDocumentData,
},
)}
>
<div className="text-center">
{!field.value && (
<>
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
<div className="mt-4 flex text-sm leading-6">
<span className="text-muted-foreground relative">
<Trans>
<span className="text-primary font-semibold">
Click to upload
</span>{' '}
or drag and drop
</Trans>
</span>
</div>
<p className="text-muted-foreground/80 text-xs">
PDF files only
</p>
</>
)}
{field.value && (
<div className="text-muted-foreground space-y-1">
<p className="text-sm font-medium">{field.value.name}</p>
<p className="text-muted-foreground/60 text-xs">
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
)}
</div>
<input
type="file"
className="absolute h-full w-full opacity-0"
accept=".pdf,application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
field.onChange(undefined);
return;
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
return;
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
),
});
return;
}
field.onChange(file);
}}
/>
{field.value && (
<div className="absolute right-2 top-2">
<Button
type="button"
variant="destructive"
className="h-6 w-6 p-0"
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
}}
>
<X className="h-4 w-4" />
<div className="sr-only">
<Trans>Clear file</Trans>
</div>
</Button>
</div>
)}
</label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Close</Trans>

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.9.0-rc.5",
"version": "1.9.0-rc.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.9.0-rc.5",
"version": "1.9.0-rc.7",
"workspaces": [
"apps/*",
"packages/*"
@@ -133,7 +133,7 @@
},
"apps/web": {
"name": "@documenso/web",
"version": "1.9.0-rc.5",
"version": "1.9.0-rc.7",
"license": "AGPL-3.0",
"dependencies": {
"@documenso/api": "*",

View File

@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.9.0-rc.5",
"version": "1.9.0-rc.7",
"scripts": {
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",

View File

@@ -1,5 +1,6 @@
import { createNextRoute } from '@ts-rest/next';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
@@ -36,10 +37,10 @@ import { deleteTemplate } from '@documenso/lib/server-only/template/delete-templ
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { extractDerivedDocumentEmailSettings } from '@documenso/lib/types/document-email';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
@@ -62,6 +63,7 @@ import {
import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated';
import { ZTemplateWithDataSchema } from './schema';
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user, team) => {
@@ -414,9 +416,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
teamId: team?.id,
});
const parsed = ZTemplateWithDataSchema.parse(template);
return {
status: 200,
body: template,
body: parsed,
};
} catch (err) {
return AppError.toRestAPIError(err);
@@ -435,10 +439,12 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
teamId: team?.id,
});
const parsed = z.array(ZTemplateWithDataSchema).parse(templates);
return {
status: 200,
body: {
templates,
templates: parsed,
totalPages,
},
};

View File

@@ -61,6 +61,7 @@ export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseS
fields: z.lazy(() =>
ZFieldSchema.pick({
id: true,
documentId: true,
recipientId: true,
type: true,
page: true,
@@ -68,6 +69,8 @@ export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseS
positionY: true,
width: true,
height: true,
customText: true,
fieldMeta: true,
})
.extend({
fieldMeta: ZFieldMetaSchema.nullish(),
@@ -524,6 +527,7 @@ export const ZFieldSchema = z.object({
height: z.unknown(),
customText: z.string(),
inserted: z.boolean(),
fieldMeta: ZFieldMetaSchema.nullish().openapi({}),
});
export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
@@ -541,6 +545,8 @@ export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
}),
Field: ZFieldSchema.pick({
id: true,
documentId: true,
templateId: true,
recipientId: true,
type: true,
page: true,
@@ -548,6 +554,8 @@ export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
positionY: true,
width: true,
height: true,
customText: true,
fieldMeta: true,
}).array(),
Recipient: ZRecipientSchema.pick({
id: true,

View File

@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@@ -157,3 +159,109 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
});
test('[TEMPLATE_FLOW] add document visibility settings', async ({ page }) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 1,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set document visibility.
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
// 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();
// Navigate back to the edit page to check that the settings are saved correctly.
await page.goto(`/t/${team.url}/templates/${template.id}/edit`);
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
});
test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 2, // Create an additional member to test different roles
});
await prisma.teamMember.update({
where: {
id: team.members[1].id,
},
data: {
role: TeamMemberRole.MANAGER,
},
});
const owner = team.owner;
const managerUser = team.members[1].user;
const memberUser = team.members[2].user;
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
// Test as manager
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Manager should be able to set visibility to managers and above
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await expect(page.getByText('Admins only')).toBeDisabled();
// Save and verify
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Test as regular member
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Regular member should not be able to modify visibility when set to managers and above
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
// Create a new template with 'everyone' visibility
const everyoneTemplate = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
visibility: 'EVERYONE',
},
});
// Navigate to the new template
await page.goto(`/t/${team.url}/templates/${everyoneTemplate.id}/edit`);
// Regular member should be able to see but not modify visibility
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Everyone');
});

View File

@@ -1,7 +1,11 @@
import { expect, test } from '@playwright/test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { DocumentDataType, TeamMemberRole } from '@documenso/prisma/client';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
@@ -13,6 +17,20 @@ test.describe.configure({ mode: 'parallel' });
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
// Create a temporary PDF file for testing
function createTempPdfFile() {
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, 'test.pdf');
// Create a simple PDF file with some content
const pdfContent = Buffer.from(
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000052 00000 n\n0000000101 00000 n\ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n178\n%%EOF',
);
fs.writeFileSync(tempFilePath, pdfContent);
return tempFilePath;
}
/**
* 1. Create a template with all settings filled out
* 2. Create a document from the template
@@ -283,3 +301,318 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
});
/**
* This test verifies that we can create a document from a template using a custom document
* instead of the template's default document.
*/
test('[TEMPLATE]: should create a document from a template with custom document', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
// Create a temporary PDF file for upload
const testPdfPath = createTempPdfFile();
const pdfContent = fs.readFileSync(testPdfPath).toString('base64');
try {
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Set template title
await page.getByLabel('Title').fill('TEMPLATE_WITH_CUSTOM_DOC');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
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 with custom document
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
// Enable custom document upload and upload file
await page.getByLabel('Upload custom document').check();
await page.locator('input[type="file"]').setInputFiles(testPdfPath);
// Wait for upload to complete
await expect(page.getByText(path.basename(testPdfPath))).toBeVisible();
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
expect(document.title).toEqual('TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
} finally {
// Clean up the temporary file
fs.unlinkSync(testPdfPath);
}
});
/**
* This test verifies that we can create a team document from a template using a custom document
* instead of the template's default document.
*/
test('[TEMPLATE]: should create a team document from a template with custom document', async ({
page,
}) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
// Create a temporary PDF file for upload
const testPdfPath = createTempPdfFile();
const pdfContent = fs.readFileSync(testPdfPath).toString('base64');
try {
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title
await page.getByLabel('Title').fill('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
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 with custom document
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
// Enable custom document upload and upload file
await page.getByLabel('Upload custom document').check();
await page.locator('input[type="file"]').setInputFiles(testPdfPath);
// Wait for upload to complete
await expect(page.getByText(path.basename(testPdfPath))).toBeVisible();
// Create document with custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
expect(document.teamId).toEqual(team.id);
expect(document.title).toEqual('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
} finally {
// Clean up the temporary file
fs.unlinkSync(testPdfPath);
}
});
/**
* This test verifies that when custom document upload is not enabled,
* the document uses the template's original document data.
*/
test('[TEMPLATE]: should create a document from a template using template document when custom document is not enabled', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Set template title
await page.getByLabel('Title').fill('TEMPLATE_WITH_ORIGINAL_DOC');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
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 without custom document
await page.waitForURL('/templates');
await page.getByRole('button', { name: 'Use Template' }).click();
// Verify custom document upload is not checked by default
await expect(page.getByLabel('Upload custom document')).not.toBeChecked();
// Create document without custom document data
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the template's document data
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
documentData: true,
},
});
const templateWithData = await prisma.template.findFirstOrThrow({
where: {
id: template.id,
},
include: {
templateDocumentData: true,
},
});
expect(document.title).toEqual('TEMPLATE_WITH_ORIGINAL_DOC');
expect(document.documentData.data).toEqual(templateWithData.templateDocumentData.data);
expect(document.documentData.initialData).toEqual(
templateWithData.templateDocumentData.initialData,
);
expect(document.documentData.type).toEqual(templateWithData.templateDocumentData.type);
});
test('[TEMPLATE]: should persist document visibility when creating from template', async ({
page,
}) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: owner.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title and visibility
await page.getByLabel('Title').fill('TEMPLATE_WITH_VISIBILITY');
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Managers and above').click();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText(
'Managers and above',
);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add a signer
await page.getByPlaceholder('Email').fill('recipient@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Test creating document as team manager
await prisma.teamMember.update({
where: {
id: team.members[1].id,
},
data: {
role: TeamMemberRole.MANAGER,
},
});
const managerUser = team.members[1].user;
await apiSignin({
page,
email: managerUser.email,
redirectPath: `/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 visibility
await page.waitForURL(/documents/);
const documentId = Number(page.url().split('/').pop());
const document = await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
});
expect(document.title).toEqual('TEMPLATE_WITH_VISIBILITY');
expect(document.visibility).toEqual('MANAGER_AND_ABOVE');
expect(document.teamId).toEqual(team.id);
// Test that regular member cannot create document from restricted template
const memberUser = team.members[2].user;
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/templates`,
});
// Template should not be visible to regular member
await expect(page.getByRole('button', { name: 'Use Template' })).not.toBeVisible();
});

View File

@@ -67,6 +67,8 @@ test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) =>
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
await page.getByRole('button', { name: 'Create one automatically' }).click();
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByTestId('btn-dialog-close').click();
// Expect badge to appear.

View File

@@ -54,6 +54,7 @@ export type CreateDocumentFromTemplateOptions = {
email: string;
signingOrder?: number | null;
}[];
customDocumentDataId?: string;
/**
* Values that will override the predefined values in the template.
@@ -90,6 +91,7 @@ export const createDocumentFromTemplate = async ({
userId,
teamId,
recipients,
customDocumentDataId,
override,
requestMetadata,
}: CreateDocumentFromTemplateOptions): Promise<TCreateDocumentFromTemplateResponse> => {
@@ -171,11 +173,29 @@ export const createDocumentFromTemplate = async ({
};
});
let parentDocumentData = template.templateDocumentData;
if (customDocumentDataId) {
const customDocumentData = await prisma.documentData.findFirst({
where: {
id: customDocumentDataId,
},
});
if (!customDocumentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Custom document data not found',
});
}
parentDocumentData = customDocumentData;
}
const documentData = await prisma.documentData.create({
data: {
type: template.templateDocumentData.type,
data: template.templateDocumentData.data,
initialData: template.templateDocumentData.initialData,
type: parentDocumentData.type,
data: parentDocumentData.data,
initialData: parentDocumentData.initialData,
},
});
@@ -193,7 +213,7 @@ export const createDocumentFromTemplate = async ({
globalAccessAuth: templateAuthOptions.globalAccessAuth,
globalActionAuth: templateAuthOptions.globalActionAuth,
}),
visibility: template.team?.teamGlobalSettings?.documentVisibility,
visibility: template.visibility || template.team?.teamGlobalSettings?.documentVisibility,
documentMeta: {
create: {
subject: override?.subject || template.templateMeta?.subject,

View File

@@ -1,7 +1,13 @@
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
import type { Prisma, Template } from '@documenso/prisma/client';
import {
DocumentVisibility,
type Prisma,
TeamMemberRole,
type Template,
} from '@documenso/prisma/client';
import {
DocumentDataSchema,
FieldSchema,
@@ -12,6 +18,7 @@ import {
TemplateSchema,
} from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
export type FindTemplatesOptions = {
@@ -52,28 +59,58 @@ export const findTemplates = async ({
page = 1,
perPage = 10,
}: FindTemplatesOptions): Promise<TFindTemplatesResponse> => {
let whereFilter: Prisma.TemplateWhereInput = {
userId,
teamId: null,
type,
};
const whereFilter: Prisma.TemplateWhereInput[] = [];
if (teamId === undefined) {
whereFilter.push({ userId, teamId: null });
}
if (teamId !== undefined) {
whereFilter = {
team: {
id: teamId,
members: {
some: {
const teamMember = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (!teamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not a member of this team.',
});
}
whereFilter.push(
{ teamId },
{
OR: [
match(teamMember.role)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
};
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })),
{ userId, teamId },
],
},
);
}
const [data, count] = await Promise.all([
prisma.template.findMany({
where: whereFilter,
where: {
type,
AND: whereFilter,
},
include: {
templateDocumentData: true,
team: {
@@ -103,7 +140,9 @@ export const findTemplates = async ({
},
}),
prisma.template.count({
where: whereFilter,
where: {
AND: whereFilter,
},
}),
]);

View File

@@ -5,7 +5,7 @@ import type { z } from 'zod';
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 { Template, TemplateMeta } from '@documenso/prisma/client';
import type { DocumentVisibility, Template, TemplateMeta } from '@documenso/prisma/client';
import { TemplateSchema } from '@documenso/prisma/generated/zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -19,6 +19,7 @@ export type UpdateTemplateSettingsOptions = {
data: {
title?: string;
externalId?: string | null;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes | null;
globalActionAuth?: TDocumentActionAuthTypes | null;
publicTitle?: string;
@@ -110,6 +111,7 @@ export const updateTemplateSettings = async ({
title: data.title,
externalId: data.externalId,
type: data.type,
visibility: data.visibility,
publicDescription: data.publicDescription,
publicTitle: data.publicTitle,
authOptions,

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "visibility" "DocumentVisibility" NOT NULL DEFAULT 'EVERYONE';

View File

@@ -660,6 +660,7 @@ model Template {
title String
userId Int
teamId Int?
visibility DocumentVisibility @default(EVERYONE)
authOptions Json?
templateMeta TemplateMeta?
templateDocumentDataId String

View File

@@ -237,8 +237,8 @@ export const templateRouter = router({
})
.input(ZCreateDocumentFromTemplateMutationSchema)
.output(ZGetDocumentWithDetailsByIdResponseSchema)
.mutation(async ({ input, ctx }) => {
const { templateId, teamId, recipients, distributeDocument } = input;
.mutation(async ({ ctx, input }) => {
const { templateId, teamId, recipients, distributeDocument, customDocumentDataId } = input;
const limits = await getServerLimits({ email: ctx.user.email, teamId });
@@ -253,6 +253,7 @@ export const templateRouter = router({
teamId,
userId: ctx.user.id,
recipients,
customDocumentDataId,
requestMetadata,
});

View File

@@ -11,6 +11,7 @@ import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import {
DocumentDistributionMethod,
DocumentSigningOrder,
DocumentVisibility,
TemplateType,
} from '@documenso/prisma/client';
@@ -47,6 +48,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
return new Set(emails).size === emails.length;
}, 'Recipients must have unique emails'),
distributeDocument: z.boolean().optional(),
customDocumentDataId: z.string().optional(),
});
export const ZDuplicateTemplateMutationSchema = z.object({
@@ -83,6 +85,7 @@ export const ZUpdateTemplateSettingsMutationSchema = z.object({
data: z.object({
title: z.string().min(1).optional(),
externalId: z.string().nullish(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZDocumentAccessAuthTypesSchema.nullable().optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.nullable().optional(),
publicTitle: z.string().trim().min(1).max(MAX_TEMPLATE_PUBLIC_TITLE_LENGTH).optional(),

View File

@@ -7,6 +7,7 @@ import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_DISTRIBUTION_METHODS } from '@documenso/lib/constants/document';
@@ -14,6 +15,7 @@ import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { DocumentDistributionMethod, type Field, type Recipient } from '@documenso/prisma/client';
import type { TemplateWithData } from '@documenso/prisma/types/template';
import {
@@ -25,6 +27,10 @@ import {
DocumentGlobalAuthActionTooltip,
} from '@documenso/ui/components/document/document-global-auth-action-select';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import {
DocumentVisibilitySelect,
DocumentVisibilityTooltip,
} from '@documenso/ui/components/document/document-visibility-select';
import {
Accordion,
AccordionContent,
@@ -66,6 +72,7 @@ export type AddTemplateSettingsFormProps = {
isEnterprise: boolean;
isDocumentPdfLoaded: boolean;
template: TemplateWithData;
currentTeamMemberRole?: TeamMemberRole;
onSubmit: (_data: TAddTemplateSettingsFormSchema) => void;
};
@@ -76,6 +83,7 @@ export const AddTemplateSettingsFormPartial = ({
isEnterprise,
isDocumentPdfLoaded,
template,
currentTeamMemberRole,
onSubmit,
}: AddTemplateSettingsFormProps) => {
const { _ } = useLingui();
@@ -89,6 +97,7 @@ export const AddTemplateSettingsFormPartial = ({
defaultValues: {
title: template.title,
externalId: template.externalId || undefined,
visibility: template.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || undefined,
globalActionAuth: documentAuthOption?.globalActionAuth || undefined,
meta: {
@@ -110,6 +119,16 @@ export const AddTemplateSettingsFormPartial = ({
const distributionMethod = form.watch('meta.distributionMethod');
const emailSettings = form.watch('meta.emailSettings');
const canUpdateVisibility = match(currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(
TeamMemberRole.MANAGER,
() =>
template.visibility === DocumentVisibility.EVERYONE ||
template.visibility === DocumentVisibility.MANAGER_AND_ABOVE,
)
.otherwise(() => false);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
@@ -210,6 +229,30 @@ export const AddTemplateSettingsFormPartial = ({
)}
/>
{currentTeamMemberRole && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
Document visibility
<DocumentVisibilityTooltip />
</FormLabel>
<FormControl>
<DocumentVisibilitySelect
canUpdateVisibility={canUpdateVisibility}
currentTeamMemberRole={currentTeamMemberRole}
{...field}
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="meta.distributionMethod"

View File

@@ -9,6 +9,7 @@ import {
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url';
import { DocumentVisibility } from '@documenso/prisma/client';
import { ZMapNegativeOneToUndefinedSchema } from '../document-flow/add-settings.types';
import { DocumentDistributionMethod } from '.prisma/client';
@@ -16,6 +17,7 @@ import { DocumentDistributionMethod } from '.prisma/client';
export const ZAddTemplateSettingsFormSchema = z.object({
title: z.string().trim().min(1, { message: "Title can't be empty" }),
externalId: z.string().optional(),
visibility: z.nativeEnum(DocumentVisibility).optional(),
globalAccessAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZDocumentAccessAuthTypesSchema.optional(),
),