From 6fc5e565d0b1d5a25517267d9141a891de5f7422 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 9 Jan 2025 01:14:24 +0200 Subject: [PATCH] fix: add document visibility to template (#1566) Adds the visibility property to templates --- .../templates/[id]/edit/edit-template.tsx | 2 + .../template-settings-step.spec.ts | 108 ++++++++++++++++++ .../create-document-from-template.spec.ts | 89 ++++++++++++++- .../e2e/templates/direct-templates.spec.ts | 2 + .../template/create-document-from-template.ts | 2 +- .../server-only/template/find-templates.ts | 73 +++++++++--- .../template/update-template-settings.ts | 4 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 13 ++- .../trpc/server/template-router/schema.ts | 2 + .../template-flow/add-template-settings.tsx | 43 +++++++ .../add-template-settings.types.tsx | 2 + 12 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 packages/prisma/migrations/20241231121013_add_visibility_to_template/migration.sql diff --git a/apps/web/src/app/(dashboard)/templates/[id]/edit/edit-template.tsx b/apps/web/src/app/(dashboard)/templates/[id]/edit/edit-template.tsx index 6276e6f0e..65b45fc5c 100644 --- a/apps/web/src/app/(dashboard)/templates/[id]/edit/edit-template.tsx +++ b/apps/web/src/app/(dashboard)/templates/[id]/edit/edit-template.tsx @@ -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 = ({ { 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'); +}); diff --git a/packages/app-tests/e2e/templates/create-document-from-template.spec.ts b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts index 637d8232e..16f155cb2 100644 --- a/packages/app-tests/e2e/templates/create-document-from-template.spec.ts +++ b/packages/app-tests/e2e/templates/create-document-from-template.spec.ts @@ -5,7 +5,7 @@ import path from 'path'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { prisma } from '@documenso/prisma'; -import { DocumentDataType } from '@documenso/prisma/client'; +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'; @@ -529,3 +529,90 @@ test('[TEMPLATE]: should create a document from a template using template docume ); 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(); +}); diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts index a15c3ff28..05612d2e6 100644 --- a/packages/app-tests/e2e/templates/direct-templates.spec.ts +++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts @@ -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. diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 0e9d0840a..be497955d 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -213,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, diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index a9b7d9075..9f9ae6587 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -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 => { - 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: { - userId, - }, - }, + 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, + }, }), ]); diff --git a/packages/lib/server-only/template/update-template-settings.ts b/packages/lib/server-only/template/update-template-settings.ts index 97d3bdbbe..3c2bfb7c1 100644 --- a/packages/lib/server-only/template/update-template-settings.ts +++ b/packages/lib/server-only/template/update-template-settings.ts @@ -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, diff --git a/packages/prisma/migrations/20241231121013_add_visibility_to_template/migration.sql b/packages/prisma/migrations/20241231121013_add_visibility_to_template/migration.sql new file mode 100644 index 000000000..472038a8b --- /dev/null +++ b/packages/prisma/migrations/20241231121013_add_visibility_to_template/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Template" ADD COLUMN "visibility" "DocumentVisibility" NOT NULL DEFAULT 'EVERYONE'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index efafbb3e6..0070229d9 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -654,19 +654,20 @@ model TemplateMeta { } model Template { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) externalId String? - type TemplateType @default(PRIVATE) + type TemplateType @default(PRIVATE) title String userId Int teamId Int? + visibility DocumentVisibility @default(EVERYONE) authOptions Json? templateMeta TemplateMeta? templateDocumentDataId String - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - publicTitle String @default("") - publicDescription String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + publicTitle String @default("") + publicDescription String @default("") team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade) diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 4adc55eef..ac68926c1 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -11,6 +11,7 @@ import { isValidRedirectUrl } from '@documenso/lib/utils/is-valid-redirect-url'; import { DocumentDistributionMethod, DocumentSigningOrder, + DocumentVisibility, TemplateType, } from '@documenso/prisma/client'; @@ -84,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(), diff --git a/packages/ui/primitives/template-flow/add-template-settings.tsx b/packages/ui/primitives/template-flow/add-template-settings.tsx index 241f7b142..f0552343c 100644 --- a/packages/ui/primitives/template-flow/add-template-settings.tsx +++ b/packages/ui/primitives/template-flow/add-template-settings.tsx @@ -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 && ( + ( + + + Document visibility + + + + + + + + )} + /> + )} +