diff --git a/.env.example b/.env.example index 1f2df8550..34aeb1c96 100644 --- a/.env.example +++ b/.env.example @@ -104,6 +104,7 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5 NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= +NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID= # [[FEATURES]] # OPTIONAL: Leave blank to disable PostHog and feature flags. diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 92209bcc7..ea672ff10 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -40,6 +40,7 @@ export type EditDocumentFormProps = { fields: Field[]; documentData: DocumentData; documentRootPath: string; + isDocumentEnterprise: boolean; }; type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject'; @@ -54,6 +55,7 @@ export const EditDocumentForm = ({ user: _user, documentData, documentRootPath, + isDocumentEnterprise, }: EditDocumentFormProps) => { const { toast } = useToast(); @@ -261,6 +263,7 @@ export const EditDocumentForm = ({ document={document} recipients={recipients} fields={fields} + isDocumentEnterprise={isDocumentEnterprise} onSubmit={onAddSettingsFormSubmit} /> @@ -269,6 +272,7 @@ export const EditDocumentForm = ({ documentFlow={documentFlow.signers} recipients={recipients} fields={fields} + isDocumentEnterprise={isDocumentEnterprise} onSubmit={onAddSignersFormSubmit} /> diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx index 69122312e..57417667d 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation'; import { ChevronLeft, Users2 } from 'lucide-react'; +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; @@ -37,6 +38,11 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie const { user } = await getRequiredServerComponentSession(); + const isDocumentEnterprise = await isUserEnterprise({ + userId: user.id, + teamId: team?.id, + }); + const document = await getDocumentById({ id: documentId, userId: user.id, @@ -116,6 +122,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie fields={fields} documentData={documentData} documentRootPath={documentRootPath} + isDocumentEnterprise={isDocumentEnterprise} /> ); diff --git a/packages/app-tests/e2e/document-flow/settings-step.spec.ts b/packages/app-tests/e2e/document-flow/settings-step.spec.ts index 87a053622..16dbfba5a 100644 --- a/packages/app-tests/e2e/document-flow/settings-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/settings-step.spec.ts @@ -1,16 +1,148 @@ import { expect, test } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; import { seedBlankDocument, seedDraftDocument, seedPendingDocument, } from '@documenso/prisma/seed/documents'; +import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; +import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams'; import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; +dotenv.config({ path: path.resolve(__dirname, '../../../../', '.env.local') }); + test.describe.configure({ mode: 'parallel' }); +test.describe('[EE_ONLY]', () => { + const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || ''; + + test.beforeEach(() => { + test.skip( + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId, + 'Billing required for this test', + ); + }); + + test('[DOCUMENT_FLOW] add action auth settings', async ({ page }) => { + const user = await seedUser(); + + await seedUserSubscription({ + userId: user.id, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Set EE action auth. + await page.getByTestId('documentActionSelectValue').click(); + await page.getByLabel('Require account').getByText('Require account').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Return to the settings step to check that the results are saved correctly. + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'General' })).toBeVisible(); + + // Todo: Verify that the values are correct once we fix the issue where going back + // does not show the updated values. + // await expect(page.getByLabel('Title')).toContainText('New Title'); + // await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); + // await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + + await unseedUser(user.id); + }); + + test('[DOCUMENT_FLOW] enterprise team member can add action auth settings', async ({ page }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const owner = team.owner; + const teamMemberUser = team.members[1].user; + + // Make the team enterprise by giving the owner the enterprise subscription. + await seedUserSubscription({ + userId: team.ownerUserId, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(owner, { + createDocumentOptions: { + teamId: team.id, + }, + }); + + await apiSignin({ + page, + email: teamMemberUser.email, + redirectPath: `/t/${team.url}/documents/${document.id}/edit`, + }); + + // Set EE action auth. + await page.getByTestId('documentActionSelectValue').click(); + await page.getByLabel('Require account').getByText('Require account').click(); + await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Advanced settings should be visible. + await expect(page.getByLabel('Show advanced settings')).toBeVisible(); + + await unseedTeam(team.url); + }); + + test('[DOCUMENT_FLOW] enterprise team member should not have access to enterprise on personal account', async ({ + page, + }) => { + const team = await seedTeam({ + createTeamMembers: 1, + }); + + const teamMemberUser = team.members[1].user; + + // Make the team enterprise by giving the owner the enterprise subscription. + await seedUserSubscription({ + userId: team.ownerUserId, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(teamMemberUser); + + await apiSignin({ + page, + email: teamMemberUser.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Global action auth should not be visible. + await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); + + // Next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Advanced settings should not be visible. + await expect(page.getByLabel('Show advanced settings')).not.toBeVisible(); + + await unseedTeam(team.url); + }); +}); + test('[DOCUMENT_FLOW]: add settings', async ({ page }) => { const user = await seedUser(); const document = await seedBlankDocument(user); @@ -29,10 +161,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => { await page.getByLabel('Require account').getByText('Require account').click(); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); - // Set action auth. - await page.getByTestId('documentActionSelectValue').click(); - await page.getByLabel('Require account').getByText('Require account').click(); - await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account'); + // Action auth should NOT be visible. + await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); // Save the settings by going to the next step. await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/app-tests/e2e/document-flow/signers-step.spec.ts b/packages/app-tests/e2e/document-flow/signers-step.spec.ts index 90c6e1d3d..51be7872a 100644 --- a/packages/app-tests/e2e/document-flow/signers-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/signers-step.spec.ts @@ -1,12 +1,71 @@ import { expect, test } from '@playwright/test'; +import dotenv from 'dotenv'; +import path from 'path'; import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions'; import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; import { apiSignin } from '../fixtures/authentication'; +dotenv.config({ path: path.resolve(__dirname, '../../../../', '.env.local') }); + test.describe.configure({ mode: 'parallel' }); +test.describe('[EE_ONLY]', () => { + const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || ''; + + test.beforeEach(() => { + test.skip( + process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId, + 'Billing required for this test', + ); + }); + + test('[DOCUMENT_FLOW] add EE settings', async ({ page }) => { + const user = await seedUser(); + + await seedUserSubscription({ + userId: user.id, + priceId: enterprisePriceId, + }); + + const document = await seedBlankDocument(user); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/documents/${document.id}/edit`, + }); + + // Save the settings by going to the next step. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Add 2 signers. + await page.getByPlaceholder('Email').fill('recipient1@documenso.com'); + await page.getByPlaceholder('Name').fill('Recipient 1'); + await page.getByRole('button', { name: 'Add Signer' }).click(); + await page + .getByRole('textbox', { name: 'Email', exact: true }) + .fill('recipient2@documenso.com'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); + + // Display advanced settings. + await page.getByLabel('Show advanced settings').click(); + + // Navigate to the next step and back. + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); + await page.getByRole('button', { name: 'Go Back' }).click(); + await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible(); + + // Todo: Fix stepper component back issue before finishing test. + + await unseedUser(user.id); + }); +}); + // Note: Not complete yet due to issue with back button. test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { const user = await seedUser(); @@ -29,8 +88,8 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); - // Display advanced settings. - await page.getByLabel('Show advanced settings').click(); + // Advanced settings should not be visible for non EE users. + await expect(page.getByLabel('Show advanced settings')).toBeHidden(); // Navigate to the next step and back. await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/ee/server-only/util/is-document-enterprise.ts b/packages/ee/server-only/util/is-document-enterprise.ts new file mode 100644 index 000000000..01c2d7327 --- /dev/null +++ b/packages/ee/server-only/util/is-document-enterprise.ts @@ -0,0 +1,56 @@ +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { subscriptionsContainActiveEnterprisePlan } from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import type { Subscription } from '@documenso/prisma/client'; + +export type IsUserEnterpriseOptions = { + userId: number; + teamId?: number; +}; + +/** + * Whether the user is enterprise, or has permission to use enterprise features on + * behalf of their team. + * + * It is assumed that the provided user is part of the provided team. + */ +export const isUserEnterprise = async ({ + userId, + teamId, +}: IsUserEnterpriseOptions): Promise => { + let subscriptions: Subscription[] = []; + + if (!IS_BILLING_ENABLED()) { + return false; + } + + if (teamId) { + subscriptions = await prisma.team + .findFirstOrThrow({ + where: { + id: teamId, + }, + select: { + owner: { + include: { + Subscription: true, + }, + }, + }, + }) + .then((team) => team.owner.Subscription); + } else { + subscriptions = await prisma.user + .findFirstOrThrow({ + where: { + id: userId, + }, + select: { + Subscription: true, + }, + }) + .then((user) => user.Subscription); + } + + return subscriptionsContainActiveEnterprisePlan(subscriptions); +}; diff --git a/packages/lib/server-only/document/update-document-settings.ts b/packages/lib/server-only/document/update-document-settings.ts index ab5943814..73e0eec3b 100644 --- a/packages/lib/server-only/document/update-document-settings.ts +++ b/packages/lib/server-only/document/update-document-settings.ts @@ -1,5 +1,6 @@ 'use server'; +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs'; @@ -74,6 +75,21 @@ export const updateDocumentSettings = async ({ const newGlobalActionAuth = data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; + // Check if user has permission to set the global action auth. + if (newGlobalActionAuth) { + const isDocumentEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isDocumentEnterprise) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'You do not have permission to set the action auth', + ); + } + } + const isTitleSame = data.title === document.title; const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth; const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth; diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index 5b4540e2d..5ccaacfe4 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -1,3 +1,4 @@ +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { type TRecipientActionAuthTypes, @@ -14,6 +15,8 @@ import { prisma } from '@documenso/prisma'; import { RecipientRole } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client'; +import { AppError, AppErrorCode } from '../../errors/app-error'; + export interface SetRecipientsForDocumentOptions { userId: number; teamId?: number; @@ -75,6 +78,23 @@ export const setRecipientsForDocument = async ({ throw new Error('Document already complete'); } + const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth); + + // Check if user has permission to set the global action auth. + if (recipientsHaveActionAuth) { + const isDocumentEnterprise = await isUserEnterprise({ + userId, + teamId, + }); + + if (!isDocumentEnterprise) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'You do not have permission to set the action auth', + ); + } + } + const normalizedRecipients = recipients.map((recipient) => ({ ...recipient, email: recipient.email.toLowerCase(), diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts index 048fa6ee0..6d2926420 100644 --- a/packages/lib/utils/billing.ts +++ b/packages/lib/utils/billing.ts @@ -1,3 +1,6 @@ +import { env } from 'next-runtime-env'; + +import { IS_BILLING_ENABLED } from '../constants/app'; import type { Subscription } from '.prisma/client'; import { SubscriptionStatus } from '.prisma/client'; @@ -13,3 +16,15 @@ export const subscriptionsContainsActivePlan = ( subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId), ); }; + +export const subscriptionsContainActiveEnterprisePlan = ( + subscriptions?: Subscription[], +): boolean => { + const enterprisePlanId = env('NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID'); + + if (!enterprisePlanId || !subscriptions || !IS_BILLING_ENABLED()) { + return false; + } + + return subscriptionsContainsActivePlan(subscriptions, [enterprisePlanId]); +}; diff --git a/packages/prisma/seed/subscriptions.ts b/packages/prisma/seed/subscriptions.ts new file mode 100644 index 000000000..8e237299f --- /dev/null +++ b/packages/prisma/seed/subscriptions.ts @@ -0,0 +1,19 @@ +import { prisma } from '..'; + +export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`; + +type SeedSubscriptionOptions = { + userId: number; + priceId: string; +}; + +export const seedUserSubscription = async ({ userId, priceId }: SeedSubscriptionOptions) => { + return await prisma.subscription.create({ + data: { + userId, + planId: Date.now().toString(), + priceId, + status: 'ACTIVE', + }, + }); +}; diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 6e3cd190f..f26216ccf 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -49,6 +49,7 @@ export type AddSettingsFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; + isDocumentEnterprise: boolean; document: DocumentWithData; onSubmit: (_data: TAddSettingsFormSchema) => void; }; @@ -57,6 +58,7 @@ export const AddSettingsFormPartial = ({ documentFlow, recipients, fields, + isDocumentEnterprise, document, onSubmit, }: AddSettingsFormProps) => { @@ -183,66 +185,67 @@ export const AddSettingsFormPartial = ({ )} /> - ( - - - Recipient action authentication - - - - + {isDocumentEnterprise && ( + ( + + + Recipient action authentication + + + + - -

- Global recipient action authentication -

+ +

+ Global recipient action authentication +

-

- The authentication required for recipients to sign fields and complete the - document. -

+

+ The authentication required for recipients to sign the signature field. +

-

- This can be overriden by setting the authentication requirements directly - on each recipient in the next step. -

+

+ This can be overriden by setting the authentication requirements + directly on each recipient in the next step. +

-
    -
  • - Require account - The recipient must be signed in -
  • -
  • - None - No authentication required -
  • -
-
-
-
+
    +
  • + Require account - The recipient must be signed in +
  • +
  • + None - No authentication required +
  • +
+ +
+
- - + + + - - {Object.values(DocumentActionAuth).map((authType) => ( - - {DOCUMENT_AUTH_TYPES[authType].value} - - ))} + + {Object.values(DocumentActionAuth).map((authType) => ( + + {DOCUMENT_AUTH_TYPES[authType].value} + + ))} - {/* Note: -1 is remapped in the Zod schema to the required value. */} - None - - - -
- )} - /> + {/* Note: -1 is remapped in the Zod schema to the required value. */} + None + + + + + )} + /> + )} diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx index 9495882a4..178589423 100644 --- a/packages/ui/primitives/document-flow/add-signers.tsx +++ b/packages/ui/primitives/document-flow/add-signers.tsx @@ -45,6 +45,7 @@ export type AddSignersFormProps = { documentFlow: DocumentFlowStep; recipients: Recipient[]; fields: Field[]; + isDocumentEnterprise: boolean; onSubmit: (_data: TAddSignersFormSchema) => void; }; @@ -52,6 +53,7 @@ export const AddSignersFormPartial = ({ documentFlow, recipients, fields, + isDocumentEnterprise, onSubmit, }: AddSignersFormProps) => { const { toast } = useToast(); @@ -243,14 +245,18 @@ export const AddSignersFormPartial = ({ )} /> - {showAdvancedSettings && ( + {showAdvancedSettings && isDocumentEnterprise && ( ( - @@ -396,7 +402,7 @@ export const AddSignersFormPartial = ({ Add Signer - {!alwaysShowAdvancedSettings && ( + {!alwaysShowAdvancedSettings && isDocumentEnterprise && (