feat: migrate nextjs to rr7

This commit is contained in:
David Nguyen
2025-01-02 15:33:37 +11:00
committed by Mythie
parent 9183f668d3
commit 75d7336763
1021 changed files with 60930 additions and 40839 deletions

40
packages/api/hono.ts Normal file
View File

@ -0,0 +1,40 @@
import { TsRestHttpError, fetchRequestHandler } from '@ts-rest/serverless/fetch';
import { Hono } from 'hono';
import { ApiContractV1 } from '@documenso/api/v1/contract';
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
import { testCredentialsHandler } from '@documenso/lib/server-only/public-api/test-credentials';
import { listDocumentsHandler } from '@documenso/lib/server-only/webhooks/zapier/list-documents';
import { subscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/subscribe';
import { unsubscribeHandler } from '@documenso/lib/server-only/webhooks/zapier/unsubscribe';
// This is bad, ts-router will be created on each request.
// But don't really have a choice here.
export const tsRestHonoApp = new Hono();
tsRestHonoApp
.get('/openapi', (c) => c.redirect('https://openapi-v1.documenso.com'))
.get('/openapi.json', (c) => c.json(OpenAPIV1))
.get('/me', async (c) => testCredentialsHandler(c.req.raw));
// Zapier. Todo: (RR7) Check methods. Are these get/post/update requests?
tsRestHonoApp
.all('/zapier/list-documents', async (c) => listDocumentsHandler(c.req.raw))
.all('/zapier/subscribe', async (c) => subscribeHandler(c.req.raw))
.all('/zapier/unsubscribe', async (c) => unsubscribeHandler(c.req.raw));
tsRestHonoApp.mount('/', async (request) => {
return fetchRequestHandler({
request,
contract: ApiContractV1,
router: ApiContractV1Implementation,
options: {
errorHandler: (err) => {
if (err instanceof TsRestHttpError && err.statusCode === 500) {
console.error(err);
}
},
},
});
});

View File

@ -1 +0,0 @@
export { createNextRouter } from '@ts-rest/next';

View File

@ -18,8 +18,8 @@
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5",
"@ts-rest/next": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
"@ts-rest/serverless": "^3.30.5",
"@types/swagger-ui-react": "^4.18.3",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
@ -27,4 +27,4 @@
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
}
}
}

View File

@ -1,31 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useTheme } from 'next-themes';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
export const OpenApiDocsPage = () => {
const { resolvedTheme } = useTheme();
useEffect(() => {
const body = document.body;
if (resolvedTheme === 'dark') {
body.classList.add('swagger-dark-theme');
} else {
body.classList.remove('swagger-dark-theme');
}
return () => {
body.classList.remove('swagger-dark-theme');
};
}, [resolvedTheme]);
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
};
export default OpenApiDocsPage;

View File

@ -1,4 +1,6 @@
import { createNextRoute } from '@ts-rest/next';
import type { Prisma } from '@prisma/client';
import { DocumentDataType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
@ -42,27 +44,20 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
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';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import {
DocumentDataType,
DocumentStatus,
SigningStatus,
TeamMemberRole,
} from '@documenso/prisma/client';
import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated';
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10;
@ -182,7 +177,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
if (document.status !== DocumentStatus.COMPLETED) {
if (!isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@ -491,14 +486,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
let documentDataId = document.documentDataId;
if (body.formValues) {
const pdf = await getFile(document.documentData);
const pdf = await getFileServerSide(document.documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
const newDocumentData = await putPdfFile({
const newDocumentData = await putPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@ -586,6 +581,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
userId: user.id,
teamId: team?.id,
recipients: body.recipients,
prefillFields: body.prefillFields,
override: {
title: body.title,
...body.meta,
@ -599,14 +595,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
if (body.formValues) {
const fileName = document.title.endsWith('.pdf') ? document.title : `${document.title}.pdf`;
const pdf = await getFile(document.documentData);
const pdf = await getFileServerSide(document.documentData);
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(pdf),
formValues: body.formValues,
});
const newDocumentData = await putPdfFile({
const newDocumentData = await putPdfFileServerSide({
name: fileName,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
@ -674,7 +670,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@ -777,7 +773,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@ -849,7 +845,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
updateRecipient: authenticatedMiddleware(async (args, user, team) => {
updateRecipient: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { id: documentId, recipientId } = args.params;
const { name, email, role, authOptions, signingOrder } = args.body;
@ -868,7 +864,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@ -887,7 +883,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
role,
signingOrder,
actionAuth: authOptions?.actionAuth,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata.requestMetadata,
}).catch(() => null);
if (!updatedRecipient) {
@ -909,7 +905,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
deleteRecipient: authenticatedMiddleware(async (args, user, team) => {
deleteRecipient: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { id: documentId, recipientId } = args.params;
const document = await getDocumentById({
@ -927,7 +923,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@ -941,7 +937,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata.requestMetadata,
}).catch(() => null);
if (!deletedRecipient) {
@ -963,7 +959,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
createField: authenticatedMiddleware(async (args, user, team) => {
createField: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { id: documentId } = args.params;
const fields = Array.isArray(args.body) ? args.body : [args.body];
@ -992,7 +988,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: { message: 'Document is already completed' },
@ -1100,7 +1096,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
fieldRecipientId: recipientId,
fieldType: field.type,
},
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata.requestMetadata,
}),
});
@ -1134,7 +1130,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
updateField: authenticatedMiddleware(async (args, user, team) => {
updateField: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { id: documentId, fieldId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY, fieldMeta } =
args.body;
@ -1154,7 +1150,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@ -1198,7 +1194,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
pageY,
pageWidth,
pageHeight,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata.requestMetadata,
fieldMeta: fieldMeta ? ZFieldMetaSchema.parse(fieldMeta) : undefined,
});
@ -1225,7 +1221,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}),
deleteField: authenticatedMiddleware(async (args, user, team) => {
deleteField: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { id: documentId, fieldId } = args.params;
const document = await getDocumentById({
@ -1242,7 +1238,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
};
}
if (document.status === DocumentStatus.COMPLETED) {
if (isDocumentCompleted(document.status)) {
return {
status: 400,
body: {
@ -1286,7 +1282,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
fieldId: Number(fieldId),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata.requestMetadata,
}).catch(() => null);
if (!deletedField) {

View File

@ -1,14 +1,22 @@
import type { NextApiRequest } from 'next';
import type { Team, User } from '@prisma/client';
import type { TsRestRequest } from '@ts-rest/serverless';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Team, User } from '@documenso/prisma/client';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
type B = {
// appRoute: any;
request: TsRestRequest;
responseHeaders: Headers;
};
export const authenticatedMiddleware = <
T extends {
req: NextApiRequest;
headers: {
authorization: string;
};
},
R extends {
status: number;
@ -16,15 +24,15 @@ export const authenticatedMiddleware = <
},
>(
handler: (
args: T,
args: T & { req: TsRestRequest },
user: User,
team: Team | null | undefined,
options: { metadata: ApiRequestMetadata },
) => Promise<R>,
) => {
return async (args: T) => {
return async (args: T, { request }: B) => {
try {
const { authorization } = args.req.headers;
const { authorization } = args.headers;
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
@ -44,7 +52,7 @@ export const authenticatedMiddleware = <
}
const metadata: ApiRequestMetadata = {
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: extractRequestMetadata(request),
source: 'apiV1',
auth: 'api',
auditUser: {
@ -54,7 +62,15 @@ export const authenticatedMiddleware = <
},
};
return await handler(args, apiToken.user, apiToken.team, { metadata });
return await handler(
{
...args,
req: request,
},
apiToken.user,
apiToken.team,
{ metadata },
);
} catch (err) {
console.log({ err: err });

View File

@ -1,5 +1,7 @@
import { generateOpenApi } from '@ts-rest/open-api';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { ApiContractV1 } from './contract';
export const OpenAPIV1 = Object.assign(
@ -11,6 +13,11 @@ export const OpenAPIV1 = Object.assign(
version: '1.0.0',
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
},
servers: [
{
url: NEXT_PUBLIC_WEBAPP_URL(),
},
],
},
{
setOperationId: true,

View File

@ -1,4 +1,16 @@
import { extendZodWithOpenApi } from '@anatine/zod-openapi';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@prisma/client';
import { z } from 'zod';
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@ -11,19 +23,7 @@ import {
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import {
DocumentDataType,
DocumentDistributionMethod,
DocumentSigningOrder,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@documenso/prisma/client';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
extendZodWithOpenApi(z);
@ -96,7 +96,7 @@ export const ZSendDocumentForSigningMutationSchema = z
'Whether to send completion emails when the document is fully signed. This will override the document email settings.',
}),
})
.or(z.literal('').transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
.or(z.any().transform(() => ({ sendEmail: true, sendCompletionEmails: undefined })));
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
@ -299,6 +299,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
prefillFields: z.array(ZFieldMetaPrefillFieldsSchema).optional(),
});
export type TGenerateDocumentFromTemplateMutationSchema = z.infer<

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
@ -22,15 +22,18 @@ test.describe('Document API', () => {
});
// Test with sendCompletionEmails: false
const response = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${document.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendCompletionEmails: false,
},
},
data: {
sendCompletionEmails: false,
},
});
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
@ -48,7 +51,7 @@ test.describe('Document API', () => {
// Test with sendCompletionEmails: true
const response2 = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`,
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${document.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
@ -110,15 +113,18 @@ test.describe('Document API', () => {
expiresIn: null,
});
const response = await request.post(`${WEBAPP_BASE_URL}/api/v1/documents/${document.id}/send`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/documents/${document.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: true,
},
},
data: {
sendEmail: true,
},
});
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);

View File

@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import {
ZFindTeamMembersResponseSchema,
@ -7,10 +8,9 @@ import {
ZSuccessfulUpdateTeamMemberResponseSchema,
ZUnsuccessfulResponseSchema,
} from '@documenso/api/v1/schema';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@ -32,11 +32,14 @@ test.describe('Team API', () => {
expiresIn: null,
});
const response = await request.get(`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members`, {
headers: {
Authorization: `Bearer ${token}`,
const response = await request.get(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
});
);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
@ -74,7 +77,7 @@ test.describe('Team API', () => {
const newUser = await seedUser();
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/invite`,
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/invite`,
{
headers: {
Authorization: `Bearer ${token}`,
@ -126,7 +129,7 @@ test.describe('Team API', () => {
expect(member).toBeTruthy();
const response = await request.put(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${member.id}`,
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
@ -171,7 +174,7 @@ test.describe('Team API', () => {
expect(member).toBeTruthy();
const response = await request.delete(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${member.id}`,
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
@ -221,7 +224,7 @@ test.describe('Team API', () => {
expect(ownerMember).toBeTruthy();
const response = await request.delete(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${ownerMember.id}`,
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${ownerMember.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
@ -261,7 +264,7 @@ test.describe('Team API', () => {
});
const response = await request.delete(
`${WEBAPP_BASE_URL}/api/v1/team/${team.id}/members/${member.id}`,
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,

View File

@ -0,0 +1,614 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe('Template Field Prefill API v1', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Advanced Fields',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Number Field',
},
},
});
// Add RADIO field
const radioField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 5,
positionY: 25,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// Add CHECKBOX field
const checkboxField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.CHECKBOX,
page: 1,
positionX: 5,
positionY: 35,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'checkbox',
label: 'Checkbox Field',
values: [
{ id: 1, value: 'Check A', checked: false },
{ id: 2, value: 'Check B', checked: false },
],
},
},
});
// Add DROPDOWN field
const dropdownField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.DROPDOWN,
page: 1,
positionX: 5,
positionY: 45,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'dropdown',
label: 'Dropdown Field',
values: [{ value: 'Select A' }, { value: 'Select B' }],
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template with prefilled fields
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Prefilled Fields',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
prefillFields: [
{
id: textField.id,
type: 'text',
label: 'Prefilled Text',
value: 'This is prefilled text',
},
{
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
},
{
id: radioField.id,
type: 'radio',
label: 'Prefilled Radio',
value: 'Option A',
},
{
id: checkboxField.id,
type: 'checkbox',
label: 'Prefilled Checkbox',
value: ['Check A', 'Check B'],
},
{
id: dropdownField.id,
type: 'dropdown',
label: 'Prefilled Dropdown',
value: 'Select B',
},
],
},
},
);
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.documentId).toBeDefined();
// 9. Verify the document was created with prefilled fields
const document = await prisma.document.findUnique({
where: {
id: responseData.documentId,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify each field has the correct prefilled values
const documentTextField = document?.fields.find(
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Prefilled Text',
text: 'This is prefilled text',
});
const documentNumberField = document?.fields.find(
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
});
const documentRadioField = document?.fields.find(
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
);
expect(documentRadioField?.fieldMeta).toMatchObject({
type: 'radio',
label: 'Prefilled Radio',
});
// Check that the correct radio option is selected
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
const selectedRadioOption = radioValues.find((option) => option.checked);
expect(selectedRadioOption?.value).toBe('Option A');
const documentCheckboxField = document?.fields.find(
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
);
expect(documentCheckboxField?.fieldMeta).toMatchObject({
type: 'checkbox',
label: 'Prefilled Checkbox',
});
// Check that the correct checkbox options are selected
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
const checkedOptions = checkboxValues.filter((option) => option.checked);
expect(checkedOptions.length).toBe(2);
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
const documentDropdownField = document?.fields.find(
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
);
expect(documentDropdownField?.fieldMeta).toMatchObject({
type: 'dropdown',
label: 'Prefilled Dropdown',
defaultValue: 'Select B',
});
// 11. Sign in as the recipient and verify the prefilled fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
// Send the document to the recipient
const sendResponse = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: false,
},
},
);
expect(sendResponse.ok()).toBeTruthy();
expect(sendResponse.status()).toBe(200);
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the prefilled fields are visible with correct values
// Text field
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
// Checkbox field
await expect(page.getByText('Check A')).toBeVisible();
await expect(page.getByText('Check B')).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
// Dropdown field
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Default Fields',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Default Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 10,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Default Number Field',
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template without prefilled fields
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Default Fields',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
},
},
);
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.documentId).toBeDefined();
// 9. Verify the document was created with default fields
const document = await prisma.document.findUnique({
where: {
id: responseData.documentId,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify fields have their default values
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Default Text Field',
});
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Default Number Field',
});
// 11. Sign in as the recipient and verify the default fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
const sendResponse = await request.post(
`${WEBAPP_BASE_URL}/api/v1/documents/${document?.id}/send`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
sendEmail: false,
},
},
);
expect(sendResponse.ok()).toBeTruthy();
expect(sendResponse.status()).toBe(200);
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the default fields are visible with correct labels
await expect(page.getByText('Default Text Field')).toBeVisible();
await expect(page.getByText('Default Number Field')).toBeVisible();
});
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template for Invalid Test',
visibility: 'EVERYONE',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add a field to the template
const field = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 100,
positionY: 100,
width: 100,
height: 50,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// 6. Try to create a document with invalid prefill value
const response = await request.post(
`${WEBAPP_BASE_URL}/api/v1/templates/${template.id}/generate-document`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
title: 'Document with Invalid Prefill',
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: 'SIGNER',
},
],
prefillFields: [
{
id: field.id,
type: 'radio',
label: 'Invalid Radio',
value: 'Non-existent Option', // This option doesn't exist
},
],
},
},
);
// 7. Verify the request fails with appropriate error
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
const errorData = await response.json();
expect(errorData.message).toContain('not found in options for RADIO field');
});
});

View File

@ -0,0 +1,602 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { TCheckboxFieldMeta, TRadioFieldMeta } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { FieldType, RecipientRole } from '@documenso/prisma/client';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
test.describe('Template Field Prefill API v2', () => {
test('should create a document from template with prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Advanced Fields V2',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Number Field',
},
},
});
// Add RADIO field
const radioField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 5,
positionY: 25,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// Add CHECKBOX field
const checkboxField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.CHECKBOX,
page: 1,
positionX: 5,
positionY: 35,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'checkbox',
label: 'Checkbox Field',
values: [
{ id: 1, value: 'Check A', checked: false },
{ id: 2, value: 'Check B', checked: false },
],
},
},
});
// Add DROPDOWN field
const dropdownField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.DROPDOWN,
page: 1,
positionX: 5,
positionY: 45,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'dropdown',
label: 'Dropdown Field',
values: [{ value: 'Select A' }, { value: 'Select B' }],
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template with prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
prefillFields: [
{
id: textField.id,
type: 'text',
label: 'Prefilled Text',
value: 'This is prefilled text',
},
{
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
},
{
id: radioField.id,
type: 'radio',
label: 'Prefilled Radio',
value: 'Option A',
},
{
id: checkboxField.id,
type: 'checkbox',
label: 'Prefilled Checkbox',
value: ['Check A', 'Check B'],
},
{
id: dropdownField.id,
type: 'dropdown',
label: 'Prefilled Dropdown',
value: 'Select B',
},
],
},
});
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.id).toBeDefined();
// 9. Verify the document was created with prefilled fields
const document = await prisma.document.findUnique({
where: {
id: responseData.id,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify each field has the correct prefilled values
const documentTextField = document?.fields.find(
(field) => field.type === FieldType.TEXT && field.fieldMeta?.type === 'text',
);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Prefilled Text',
text: 'This is prefilled text',
});
const documentNumberField = document?.fields.find(
(field) => field.type === FieldType.NUMBER && field.fieldMeta?.type === 'number',
);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
});
const documentRadioField = document?.fields.find(
(field) => field.type === FieldType.RADIO && field.fieldMeta?.type === 'radio',
);
expect(documentRadioField?.fieldMeta).toMatchObject({
type: 'radio',
label: 'Prefilled Radio',
});
// Check that the correct radio option is selected
const radioValues = (documentRadioField?.fieldMeta as TRadioFieldMeta)?.values || [];
const selectedRadioOption = radioValues.find((option) => option.checked);
expect(selectedRadioOption?.value).toBe('Option A');
const documentCheckboxField = document?.fields.find(
(field) => field.type === FieldType.CHECKBOX && field.fieldMeta?.type === 'checkbox',
);
expect(documentCheckboxField?.fieldMeta).toMatchObject({
type: 'checkbox',
label: 'Prefilled Checkbox',
});
// Check that the correct checkbox options are selected
const checkboxValues = (documentCheckboxField?.fieldMeta as TCheckboxFieldMeta)?.values || [];
const checkedOptions = checkboxValues.filter((option) => option.checked);
expect(checkedOptions.length).toBe(2);
expect(checkedOptions.map((option) => option.value)).toContain('Check A');
expect(checkedOptions.map((option) => option.value)).toContain('Check B');
const documentDropdownField = document?.fields.find(
(field) => field.type === FieldType.DROPDOWN && field.fieldMeta?.type === 'dropdown',
);
expect(documentDropdownField?.fieldMeta).toMatchObject({
type: 'dropdown',
label: 'Prefilled Dropdown',
defaultValue: 'Select B',
});
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
documentId: document?.id,
meta: {
subject: 'Test Subject',
message: 'Test Message',
},
},
});
await expect(sendResponse.ok()).toBeTruthy();
await expect(sendResponse.status()).toBe(200);
// 11. Sign in as the recipient and verify the prefilled fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
// Verify the prefilled fields are visible with correct values
// Text field
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
await expect(page.getByRole('radio', { name: 'Option A' })).toBeChecked();
// Checkbox field
await expect(page.getByText('Check A')).toBeVisible();
await expect(page.getByText('Check B')).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Check A' })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Check B' })).toBeChecked();
// Dropdown field
await expect(page.getByText('Select B')).toBeVisible();
});
test('should create a document from template without prefilled fields', async ({
page,
request,
}) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template with Default Fields V2',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.TEXT,
page: 1,
positionX: 5,
positionY: 5,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'text',
label: 'Default Text Field',
},
},
});
// Add NUMBER field
const numberField = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.NUMBER,
page: 1,
positionX: 5,
positionY: 15,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: {
type: 'number',
label: 'Default Number Field',
},
},
});
// 6. Sign in as the user
await apiSignin({
page,
email: user.email,
});
// 7. Navigate to the template
await page.goto(`${WEBAPP_BASE_URL}/templates/${template.id}`);
// 8. Create a document from the template without prefilled fields using v2 API
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
},
});
const responseData = await response.json();
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.id).toBeDefined();
// 9. Verify the document was created with default fields
const document = await prisma.document.findUnique({
where: {
id: responseData.id,
},
include: {
fields: true,
},
});
expect(document).not.toBeNull();
// 10. Verify fields have their default values
const documentTextField = document?.fields.find((field) => field.type === FieldType.TEXT);
expect(documentTextField?.fieldMeta).toMatchObject({
type: 'text',
label: 'Default Text Field',
});
const documentNumberField = document?.fields.find((field) => field.type === FieldType.NUMBER);
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Default Number Field',
});
const sendResponse = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/document/distribute`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
documentId: document?.id,
meta: {
subject: 'Test Subject',
message: 'Test Message',
},
},
});
await expect(sendResponse.ok()).toBeTruthy();
await expect(sendResponse.status()).toBe(200);
// 11. Sign in as the recipient and verify the default fields are visible
const documentRecipient = await prisma.recipient.findFirst({
where: {
documentId: document?.id,
email: 'recipient@example.com',
},
});
expect(documentRecipient).not.toBeNull();
// Visit the signing page
await page.goto(`${WEBAPP_BASE_URL}/sign/${documentRecipient?.token}`);
await expect(page.getByText('This is prefilled')).not.toBeVisible();
});
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Template for Invalid Test V2',
visibility: 'EVERYONE',
},
});
// 4. Create a recipient for the template
const recipient = await prisma.recipient.create({
data: {
templateId: template.id,
email: 'recipient@example.com',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
token: 'test-token',
readStatus: 'NOT_OPENED',
sendStatus: 'NOT_SENT',
signingStatus: 'NOT_SIGNED',
},
});
// 5. Add a field to the template
const field = await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
type: FieldType.RADIO,
page: 1,
positionX: 100,
positionY: 100,
width: 100,
height: 50,
customText: '',
inserted: false,
fieldMeta: {
type: 'radio',
label: 'Radio Field',
values: [
{ id: 1, value: 'Option A', checked: false },
{ id: 2, value: 'Option B', checked: false },
],
},
},
});
// 7. Try to create a document with invalid prefill value
const response = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
templateId: template.id,
recipients: [
{
id: recipient.id,
email: 'recipient@example.com',
name: 'Test Recipient',
},
],
prefillFields: [
{
id: field.id,
type: 'radio',
label: 'Invalid Radio',
value: 'Non-existent Option', // This option doesn't exist
},
],
},
});
// 8. Verify the request fails with appropriate error
expect(response.ok()).toBeFalsy();
expect(response.status()).toBe(400);
const errorData = await response.json();
expect(errorData.message).toContain('not found in options for RADIO field');
});
});

View File

@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import {
createDocumentAuthOptions,
createRecipientAuthOptions,
} from '@documenso/lib/utils/document-auth';
import { FieldType } from '@documenso/prisma/client';
import {
seedPendingDocumentNoFields,
seedPendingDocumentWithFullFields,
@ -13,6 +13,7 @@ import {
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
@ -35,15 +36,7 @@ test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Add signature.
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await signSignaturePad(page);
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
@ -92,15 +85,7 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
// Add signature.
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await signSignaturePad(page);
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
@ -261,15 +246,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
});
}
// Add signature.
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await signSignaturePad(page);
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
@ -372,15 +349,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
});
}
// Add signature.
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await signSignaturePad(page);
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();

View File

@ -1,16 +1,16 @@
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import {
DocumentSigningOrder,
DocumentStatus,
FieldType,
RecipientRole,
SigningStatus,
} from '@documenso/prisma/client';
} from '@prisma/client';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { prisma } from '@documenso/prisma';
import {
seedBlankDocument,
seedPendingDocumentWithFullFields,
@ -18,6 +18,7 @@ import {
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
// Can't use the function in server-only/document due to it indirectly using
// require imports.
@ -368,15 +369,7 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
}),
).toBeVisible();
// Add signature.
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await signSignaturePad(page);
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
@ -384,7 +377,9 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Complete' : 'Approve' })
.click();
await page
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
.click();
@ -454,7 +449,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
await page.getByRole('button', { name: 'Approve' }).click();
@ -540,12 +535,19 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
if (i > 1) {
await page.getByRole('button', { name: 'Add Signer' }).click();
}
await page
.getByPlaceholder('Email')
.getByLabel('Email')
.nth(i - 1)
.focus();
await page
.getByLabel('Email')
.nth(i - 1)
.fill(`user${i}@example.com`);
await page
.getByPlaceholder('Name')
.getByLabel('Name')
.nth(i - 1)
.fill(`User ${i}`);
}
@ -607,19 +609,10 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.goto(`/sign/${recipient?.token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
const canvas = page.locator('canvas#signature');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await page.getByRole('button', { name: 'Sign', exact: true }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();

View File

@ -1,15 +1,16 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { PDFDocument } from 'pdf-lib';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
test.describe('Signing Certificate Tests', () => {
test('individual document should always include signing certificate', async ({ page }) => {
@ -36,14 +37,7 @@ test.describe('Signing Certificate Tests', () => {
// Sign the document
await page.goto(`/sign/${recipient.token}`);
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await signSignaturePad(page);
for (const field of recipient.fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
@ -113,14 +107,7 @@ test.describe('Signing Certificate Tests', () => {
// Sign the document
await page.goto(`/sign/${recipient.token}`);
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await signSignaturePad(page);
for (const field of recipient.fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
@ -190,14 +177,7 @@ test.describe('Signing Certificate Tests', () => {
// Sign the document
await page.goto(`/sign/${recipient.token}`);
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await signSignaturePad(page);
for (const field of recipient.fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();

View File

@ -1,6 +1,6 @@
import { type Page } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
type LoginOptions = {
page: Page;
@ -23,41 +23,35 @@ export const apiSignin = async ({
const csrfToken = await getCsrfToken(page);
await request.post(`${WEBAPP_BASE_URL}/api/auth/callback/credentials`, {
form: {
await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/email-password/authorize`, {
data: {
email,
password,
json: true,
csrfToken,
},
});
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${redirectPath}`);
await page.waitForTimeout(500);
};
export const apiSignout = async ({ page }: { page: Page }) => {
const { request } = page.context();
const csrfToken = await getCsrfToken(page);
await request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/signout`);
await request.post(`${WEBAPP_BASE_URL}/api/auth/signout`, {
form: {
csrfToken,
json: true,
},
});
await page.goto(`${WEBAPP_BASE_URL}/signin`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
};
const getCsrfToken = async (page: Page) => {
const { request } = page.context();
const response = await request.fetch(`${WEBAPP_BASE_URL}/api/auth/csrf`, {
const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/csrf`, {
method: 'get',
});
const { csrfToken } = await response.json();
if (!csrfToken) {
throw new Error('Invalid session');
}

View File

@ -0,0 +1,40 @@
import type { Page } from '@playwright/test';
export const signSignaturePad = async (page: Page) => {
await page.waitForTimeout(200);
const canvas = page.getByTestId('signature-pad');
const box = await canvas.boundingBox();
if (!box) {
throw new Error('Signature pad not found');
}
// Calculate center point
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
// Calculate square size (making it slightly smaller than the canvas)
const squareSize = Math.min(box.width, box.height) * 0.4; // 40% of the smallest dimension
// Move to center
await page.mouse.move(centerX, centerY);
await page.mouse.down();
// Draw square clockwise from center
// Move right
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
// Move down
await page.mouse.move(centerX + squareSize, centerY + squareSize, { steps: 10 });
// Move left
await page.mouse.move(centerX - squareSize, centerY + squareSize, { steps: 10 });
// Move up
await page.mouse.move(centerX - squareSize, centerY - squareSize, { steps: 10 });
// Move right
await page.mouse.move(centerX + squareSize, centerY - squareSize, { steps: 10 });
// Move down to close the square
await page.mouse.move(centerX + squareSize, centerY, { steps: 10 });
await page.mouse.up();
};

View File

@ -0,0 +1,144 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[PUBLIC_PROFILE]: create profile', async ({ page }) => {
const user = await seedUser();
// Create direct template.
const directTemplate = await seedDirectTemplate({
userId: user.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: '/settings/public-profile',
});
const publicProfileUrl = Date.now().toString();
const publicProfileBio = `public-profile-bio`;
await page.getByRole('textbox', { name: 'Public profile URL' }).click();
await page.getByRole('textbox', { name: 'Public profile URL' }).fill(publicProfileUrl);
await page.getByRole('textbox', { name: 'Bio' }).click();
await page.getByRole('textbox', { name: 'Bio' }).fill(publicProfileBio);
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByRole('status').first()).toContainText(
'Your public profile has been updated.',
);
// Link direct template to public profile.
await page.getByRole('button', { name: 'Link template' }).click();
await page.getByRole('cell', { name: directTemplate.title }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('textbox', { name: 'Title *' }).fill('public-direct-template-title');
await page
.getByRole('textbox', { name: 'Description *' })
.fill('public-direct-template-description');
await page.getByRole('button', { name: 'Update' }).click();
// Check that public profile is disabled.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
await expect(page.locator('body')).toContainText('404 Profile not found');
// Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/public-profile`);
await page.getByRole('switch').click();
// Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
await expect(page.getByRole('main')).toContainText(publicProfileBio);
await expect(page.locator('body')).toContainText('public-direct-template-title');
await expect(page.locator('body')).toContainText('public-direct-template-description');
await page.getByRole('link', { name: 'Sign' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
await expect(page.getByRole('heading')).toContainText('Document Signed');
});
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const user = team.owner;
// Create direct template.
const directTemplate = await seedDirectTemplate({
userId: user.id,
teamId: team.id,
});
// Create non team template to make sure you can only see the team one.
// Will be indirectly asserted because test should fail when 2 elements appear.
await seedDirectTemplate({
userId: user.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/settings/public-profile`,
});
const publicProfileUrl = team.url;
const publicProfileBio = `public-profile-bio`;
await page.getByRole('textbox', { name: 'Bio' }).click();
await page.getByRole('textbox', { name: 'Bio' }).fill(publicProfileBio);
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByRole('status').first()).toContainText(
'Your public profile has been updated.',
);
// Link direct template to public profile.
await page.getByRole('button', { name: 'Link template' }).click();
await page.getByRole('cell', { name: directTemplate.title }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('textbox', { name: 'Title *' }).fill('public-direct-template-title');
await page
.getByRole('textbox', { name: 'Description *' })
.fill('public-direct-template-description');
await page.getByRole('button', { name: 'Update' }).click();
// Check that public profile is disabled.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
await expect(page.locator('body')).toContainText('404 Profile not found');
// Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
await page.getByRole('switch').click();
// Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
await expect(page.getByRole('main')).toContainText(publicProfileBio);
await expect(page.locator('body')).toContainText('public-direct-template-title');
await expect(page.locator('body')).toContainText('public-direct-template-description');
await page.getByRole('link', { name: 'Sign' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
await expect(page.getByRole('heading')).toContainText('Document Signed');
});

View File

@ -1,6 +1,6 @@
import { test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@ -50,7 +50,7 @@ test('[TEAMS]: delete team', async ({ page }) => {
await page.getByRole('button', { name: 'Delete' }).click();
// Check that we have been redirected to the teams page.
await page.waitForURL(`${WEBAPP_BASE_URL}/settings/teams`);
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/teams`);
});
test('[TEAMS]: update team', async ({ page }) => {
@ -81,5 +81,5 @@ test('[TEAMS]: update team', async ({ page }) => {
await page.getByRole('button', { name: 'Update team' }).click();
// Check we have been redirected to the new team URL and the name is updated.
await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`);
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
});

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@ -61,7 +61,7 @@ test('[TEAMS]: search respects team document visibility', async ({ page }) => {
});
await page.getByPlaceholder('Search documents...').fill('Searchable');
await page.waitForURL(/search=Searchable/);
await page.waitForURL(/query=Searchable/);
await checkDocumentTabCount(page, 'All', visibleDocs);
@ -103,7 +103,7 @@ test('[TEAMS]: search does not reveal documents from other teams', async ({ page
});
await page.getByPlaceholder('Search documents...').fill('Unique');
await page.waitForURL(/search=Unique/);
await page.waitForURL(/query=Unique/);
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Unique Team A Document' })).toBeVisible();
@ -144,7 +144,7 @@ test('[PERSONAL]: search does not reveal team documents in personal account', as
});
await page.getByPlaceholder('Search documents...').fill('Unique');
await page.waitForURL(/search=Unique/);
await page.waitForURL(/query=Unique/);
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Personal Unique Document' })).toBeVisible();
@ -179,7 +179,7 @@ test('[TEAMS]: search respects recipient visibility regardless of team visibilit
});
await page.getByPlaceholder('Search documents...').fill('Admin Document');
await page.waitForURL(/search=Admin(%20|\+|\s)Document/);
await page.waitForURL(/query=Admin(%20|\+|\s)Document/);
await checkDocumentTabCount(page, 'All', 1);
await expect(
@ -221,7 +221,7 @@ test('[TEAMS]: search by recipient name respects visibility', async ({ page }) =
});
await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
await page.waitForURL(/query=Unique(%20|\+|\s)Recipient/);
await checkDocumentTabCount(page, 'All', 1);
await expect(
@ -238,7 +238,7 @@ test('[TEAMS]: search by recipient name respects visibility', async ({ page }) =
});
await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
await page.waitForURL(/query=Unique(%20|\+|\s)Recipient/);
await checkDocumentTabCount(page, 'All', 0);
await expect(

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
@ -113,7 +113,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
redirectPath: `/t/${team.url}/documents?perPage=20`,
});
// Check document counts.
@ -422,10 +422,13 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
},
]);
const teamUrlRedirect = `/t/${team.url}/documents?status=COMPLETED`;
// Test cases for each role
const testCases = [
{
user: adminUser,
path: teamUrlRedirect,
expectedDocuments: [
'Document Visible to Everyone',
'Document Visible to Manager and Above',
@ -435,14 +438,17 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
},
{
user: managerUser,
path: teamUrlRedirect,
expectedDocuments: ['Document Visible to Everyone', 'Document Visible to Manager and Above'],
},
{
user: memberUser,
path: teamUrlRedirect,
expectedDocuments: ['Document Visible to Everyone'],
},
{
user: outsideUser,
path: '/documents',
expectedDocuments: ['Document Visible to Admin with Recipient'],
},
];
@ -451,7 +457,7 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
await apiSignin({
page,
email: testCase.user.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
redirectPath: testCase.path,
});
// Check that the user sees the expected documents

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@ -43,7 +43,7 @@ test('[TEAMS]: accept team email request', async ({ page }) => {
teamId: team.id,
});
await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/email/${teamEmailVerification.token}`);
await expect(page.getByRole('heading')).toContainText('Team email verified!');
});

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamInvite } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@ -33,7 +33,6 @@ test('[TEAMS]: update team member role', async ({ page }) => {
await page.getByLabel('Manager').click();
await page.getByRole('button', { name: 'Update' }).click();
// TODO: Remove me, but i don't care for now
await page.reload();
await expect(
@ -49,7 +48,7 @@ test('[TEAMS]: accept team invitation without account', async ({ page }) => {
teamId: team.id,
});
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Team invitation');
});
@ -62,7 +61,7 @@ test('[TEAMS]: accept team invitation with account', async ({ page }) => {
teamId: team.id,
});
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
});

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamTransfer } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
@ -60,6 +60,6 @@ test.skip('[TEAMS]: accept team transfer', async ({ page }) => {
newOwnerUserId: newOwnerMember.userId,
});
await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/transfer/${teamTransferRequest.token}`);
await expect(page.getByRole('heading')).toContainText('Team ownership transferred!');
});

View File

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
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';
@ -12,7 +12,7 @@ import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
const enterprisePriceId = '';
test.beforeEach(() => {
test.skip(

View File

@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test';
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
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';
@ -15,7 +15,7 @@ import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
const enterprisePriceId = '';
// Create a temporary PDF file for testing
function createTempPdfFile() {

View File

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
@ -52,8 +52,8 @@ test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) =>
});
const urls = [
`${WEBAPP_BASE_URL}/t/${team.url}/templates/${teamTemplate.id}`,
`${WEBAPP_BASE_URL}/templates/${personalTemplate.id}`,
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates/${teamTemplate.id}`,
`${NEXT_PUBLIC_WEBAPP_URL()}/templates/${personalTemplate.id}`,
];
// Run test for personal and team templates.
@ -108,7 +108,7 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Navigate to template settings and disable access.
await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatTemplatesPath(template.team?.url)}`);
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Direct link' }).click();
await page.getByRole('switch').click();
@ -117,7 +117,7 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('Template not found')).toBeVisible();
await expect(page.getByText('404 not found')).toBeVisible();
}
});
@ -153,7 +153,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Navigate to template settings and delete the access.
await page.goto(`${WEBAPP_BASE_URL}${formatTemplatesPath(template.team?.url)}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatTemplatesPath(template.team?.url)}`);
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Direct link' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
@ -162,7 +162,7 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('Template not found')).toBeVisible();
await expect(page.getByText('404 not found')).toBeVisible();
}
});
@ -241,7 +241,7 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
// Check that the owner has the documents.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(template.team?.url)}`);
await expect(async () => {
// Check that the document is in the 'All' tab.
@ -314,7 +314,7 @@ test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({
// Check that the owner has the documents.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
await page.goto(`${WEBAPP_BASE_URL}${formatDocumentsPath(template.team?.url)}`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(template.team?.url)}`);
// Check that the document is in the 'All' tab.
await checkDocumentTabCount(page, 'All', 1);

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
@ -42,13 +42,12 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
redirectPath: '/templates',
});
// Owner should see both team templates.
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 2 results');
// Only should only see their personal template.
await page.goto(`${WEBAPP_BASE_URL}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
// Owner should see both team templates.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
test('[TEMPLATES]: delete template', async ({ page }) => {
@ -92,7 +91,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
await expect(page.getByText('Template deleted').first()).toBeVisible();
// Team member should be able to delete all templates.
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
for (const template of ['Team template 1', 'Team template 2']) {
await page
@ -142,16 +141,16 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByRole('main')).toContainText('Showing 2 results');
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
// Duplicate team template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByRole('main')).toContainText('Showing 2 results');
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
test('[TEMPLATES]: use template', async ({ page }) => {
@ -194,9 +193,9 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
await page.waitForTimeout(1000);
// Use team template.
@ -212,5 +211,5 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.waitForURL(/\/t\/.+\/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
});

View File

@ -6,6 +6,8 @@ import {
seedUser,
} from '@documenso/prisma/seed/users';
import { signSignaturePad } from '../fixtures/signature';
test.use({ storageState: { cookies: [], origins: [] } });
test('[USER] can sign up with email and password', async ({ page }: { page: Page }) => {
@ -18,14 +20,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password', { exact: true }).fill(password);
const canvas = page.locator('canvas').first();
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + 40, box.y + 40);
await page.mouse.down();
await page.mouse.move(box.x + box.width - 2, box.y + box.height - 2);
await page.mouse.up();
}
await signSignaturePad(page);
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByLabel('Public profile username').fill(Date.now().toString());
@ -41,7 +36,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
await expect(page.getByRole('heading')).toContainText('Email Confirmed!');
// We now automatically redirect to the home page
// await page.getByRole('link', { name: 'Go back home' }).click();
await page.getByRole('link', { name: 'Continue' }).click();
await page.waitForURL('/documents');

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users';
@ -17,7 +17,7 @@ test('[USER] delete account', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
// Verify that the user no longer exists in the database
await expect(getUserByEmail({ email: user.email })).rejects.toThrow();

View File

@ -0,0 +1,94 @@
import { type Page, expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
test.use({ storageState: { cookies: [], origins: [] } });
test('[USER] can reset password via forgot password', async ({ page }: { page: Page }) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const user = await seedUser({
password: oldPassword,
});
await page.goto('http://localhost:3000/signin');
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Reset email sent');
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
where: {
userId: user.id,
},
include: {
user: true,
},
});
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
// Assert that password cannot be same as old password.
await page.getByRole('textbox', { name: 'Password', exact: true }).fill(oldPassword);
await page.getByRole('textbox', { name: 'Repeat Password' }).fill(oldPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText(
'Your new password cannot be the same as your old password.',
);
// Assert password reset.
await page.getByRole('textbox', { name: 'Password', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Repeat Password' }).fill(newPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
// Assert sign in works.
await apiSignin({
page,
email: user.email,
password: newPassword,
});
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
});
test('[USER] can reset password via user settings', async ({ page }: { page: Page }) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const user = await seedUser({
password: oldPassword,
});
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/security',
});
await page.getByRole('textbox', { name: 'Current password' }).fill(oldPassword);
await page.getByRole('textbox', { name: 'New password' }).fill(newPassword);
await page.getByRole('textbox', { name: 'Repeat password' }).fill(newPassword);
await page.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('Password updated');
await apiSignout({
page,
});
// Assert sign in works.
await apiSignin({
page,
email: user.email,
password: newPassword,
});
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
});

View File

@ -7,7 +7,7 @@
"scripts": {
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
},
"keywords": [],
"author": "",
@ -16,7 +16,6 @@
"@types/node": "^20",
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@documenso/web": "*",
"pdf-lib": "^1.17.1"
},
"dependencies": {

View File

@ -0,0 +1,191 @@
import type { ClientResponse, InferRequestType } from 'hono/client';
import { hc } from 'hono/client';
import superjson from 'superjson';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import type { AuthAppType } from '../server';
import type { SessionValidationResult } from '../server/lib/session/session';
import { handleSignInRedirect } from '../server/lib/utils/redirect';
import type {
TDisableTwoFactorRequestSchema,
TEnableTwoFactorRequestSchema,
TViewTwoFactorRecoveryCodesRequestSchema,
} from '../server/routes/two-factor.types';
import type {
TForgotPasswordSchema,
TResendVerifyEmailSchema,
TResetPasswordSchema,
TSignUpSchema,
TUpdatePasswordSchema,
TVerifyEmailSchema,
} from '../server/types/email-password';
type AuthClientType = ReturnType<typeof hc<AuthAppType>>;
type TEmailPasswordSignin = InferRequestType<
AuthClientType['email-password']['authorize']['$post']
>['json'] & { redirectPath?: string };
type TPasskeySignin = InferRequestType<AuthClientType['passkey']['authorize']['$post']>['json'] & {
redirectPath?: string;
};
export class AuthClient {
public client: AuthClientType;
private signOutredirectPath: string = '/signin';
constructor(options: { baseUrl: string }) {
this.client = hc<AuthAppType>(options.baseUrl);
}
public async signOut({ redirectPath }: { redirectPath?: string } = {}) {
await this.client.signout.$post();
window.location.href = redirectPath ?? this.signOutredirectPath;
}
public async getSession() {
const response = await this.client['session-json'].$get();
await this.handleError(response);
const result = await response.json();
return superjson.deserialize<SessionValidationResult>(result);
}
private async handleError<T>(response: ClientResponse<T>): Promise<void> {
if (!response.ok) {
const error = await response.json();
throw AppError.parseError(error);
}
}
public emailPassword = {
signIn: async (data: Omit<TEmailPasswordSignin, 'csrfToken'> & { csrfToken?: string }) => {
let csrfToken = data.csrfToken;
if (!csrfToken) {
csrfToken = (await this.client.csrf.$get().then(async (res) => res.json())).csrfToken;
}
const response = await this.client['email-password'].authorize.$post({
json: {
...data,
csrfToken,
},
});
await this.handleError(response);
handleSignInRedirect(data.redirectPath);
},
updatePassword: async (data: TUpdatePasswordSchema) => {
const response = await this.client['email-password']['update-password'].$post({ json: data });
await this.handleError(response);
},
forgotPassword: async (data: TForgotPasswordSchema) => {
const response = await this.client['email-password']['forgot-password'].$post({ json: data });
await this.handleError(response);
},
resetPassword: async (data: TResetPasswordSchema) => {
const response = await this.client['email-password']['reset-password'].$post({ json: data });
await this.handleError(response);
},
signUp: async (data: TSignUpSchema) => {
const response = await this.client['email-password']['signup'].$post({ json: data });
await this.handleError(response);
},
resendVerifyEmail: async (data: TResendVerifyEmailSchema) => {
const response = await this.client['email-password']['resend-verify-email'].$post({
json: data,
});
await this.handleError(response);
},
verifyEmail: async (data: TVerifyEmailSchema) => {
const response = await this.client['email-password']['verify-email'].$post({ json: data });
await this.handleError(response);
return response.json();
},
};
public twoFactor = {
setup: async () => {
const response = await this.client['two-factor'].setup.$post();
await this.handleError(response);
return response.json();
},
enable: async (data: TEnableTwoFactorRequestSchema) => {
const response = await this.client['two-factor'].enable.$post({ json: data });
await this.handleError(response);
return response.json();
},
disable: async (data: TDisableTwoFactorRequestSchema) => {
const response = await this.client['two-factor'].disable.$post({ json: data });
await this.handleError(response);
},
viewRecoveryCodes: async (data: TViewTwoFactorRecoveryCodesRequestSchema) => {
const response = await this.client['two-factor']['view-recovery-codes'].$post({ json: data });
await this.handleError(response);
return response.json();
},
};
public passkey = {
signIn: async (data: TPasskeySignin) => {
const response = await this.client['passkey'].authorize.$post({ json: data });
await this.handleError(response);
handleSignInRedirect(data.redirectPath);
},
};
public google = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.google.$post({
json: { redirectPath },
});
await this.handleError(response);
const data = await response.json();
// Redirect to external Google auth URL.
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
};
public oidc = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });
await this.handleError(response);
const data = await response.json();
// Redirect to external OIDC provider URL.
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
};
}
export const authClient = new AuthClient({
baseUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth`,
});

2
packages/auth/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './server/lib/errors/errors';
export * from './server/lib/errors/error-codes';

View File

@ -0,0 +1,25 @@
{
"name": "@documenso/auth",
"version": "0.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "MIT",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rimraf node_modules"
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@hono/standard-validator": "^0.1.2",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"arctic": "^3.1.0",
"hono": "4.7.0",
"luxon": "^3.5.0",
"nanoid": "^4.0.2",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
}
}

View File

@ -0,0 +1,37 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { env } from '@documenso/lib/utils/env';
/**
* How long a session should live for in milliseconds.
*/
export const AUTH_SESSION_LIFETIME = 1000 * 60 * 60 * 24 * 30; // 30 days.
export type OAuthClientOptions = {
id: string;
scope: string[];
clientId: string;
clientSecret: string;
wellKnownUrl: string;
redirectUrl: string;
bypassEmailVerification?: boolean;
};
export const GoogleAuthOptions: OAuthClientOptions = {
id: 'google',
scope: ['openid', 'email', 'profile'],
clientId: env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/google`,
wellKnownUrl: 'https://accounts.google.com/.well-known/openid-configuration',
bypassEmailVerification: false,
};
export const OidcAuthOptions: OAuthClientOptions = {
id: 'oidc',
scope: ['openid', 'email', 'profile'],
clientId: env('NEXT_PRIVATE_OIDC_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_OIDC_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/oidc`,
wellKnownUrl: env('NEXT_PRIVATE_OIDC_WELL_KNOWN') ?? '',
bypassEmailVerification: env('NEXT_PRIVATE_OIDC_SKIP_VERIFY') === 'true',
};

View File

@ -0,0 +1,92 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { setCsrfCookie } from './lib/session/session-cookies';
import { callbackRoute } from './routes/callback';
import { emailPasswordRoute } from './routes/email-password';
import { oauthRoute } from './routes/oauth';
import { passkeyRoute } from './routes/passkey';
import { sessionRoute } from './routes/session';
import { signOutRoute } from './routes/sign-out';
import { twoFactorRoute } from './routes/two-factor';
import type { HonoAuthContext } from './types/context';
// Note: You must chain routes for Hono RPC client to work.
export const auth = new Hono<HonoAuthContext>()
.use(async (c, next) => {
c.set('requestMetadata', extractRequestMetadata(c.req.raw));
const validOrigin = new URL(NEXT_PUBLIC_WEBAPP_URL()).origin;
const headerOrigin = c.req.header('Origin');
if (headerOrigin && headerOrigin !== validOrigin) {
return c.json(
{
message: 'Forbidden',
statusCode: 403,
},
403,
);
}
await next();
})
.get('/csrf', async (c) => {
const csrfToken = await setCsrfCookie(c);
return c.json({ csrfToken });
})
.route('/', sessionRoute)
.route('/', signOutRoute)
.route('/callback', callbackRoute)
.route('/oauth', oauthRoute)
.route('/email-password', emailPasswordRoute)
.route('/passkey', passkeyRoute)
.route('/two-factor', twoFactorRoute);
/**
* Handle errors.
*/
auth.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json(
{
code: AppErrorCode.UNKNOWN_ERROR,
message: err.message,
statusCode: err.status,
},
err.status,
);
}
if (err instanceof AppError) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const statusCode = (err.statusCode || 500) as ContentfulStatusCode;
return c.json(
{
code: err.code,
message: err.message,
statusCode: err.statusCode,
},
statusCode,
);
}
// Handle other errors
return c.json(
{
code: AppErrorCode.UNKNOWN_ERROR,
message: 'Internal Server Error',
statusCode: 500,
},
500,
);
});
export type AuthAppType = typeof auth;

View File

@ -0,0 +1,29 @@
export const AuthenticationErrorCode = {
AccountDisabled: 'ACCOUNT_DISABLED',
Unauthorized: 'UNAUTHORIZED',
InvalidCredentials: 'INVALID_CREDENTIALS',
SessionNotFound: 'SESSION_NOT_FOUND',
SessionExpired: 'SESSION_EXPIRED',
InvalidToken: 'INVALID_TOKEN',
MissingToken: 'MISSING_TOKEN',
InvalidRequest: 'INVALID_REQUEST',
UnverifiedEmail: 'UNVERIFIED_EMAIL',
NotFound: 'NOT_FOUND',
NotSetup: 'NOT_SETUP',
// InternalSeverError: 'INTERNAL_SEVER_ERROR',
// TwoFactorAlreadyEnabled: 'TWO_FACTOR_ALREADY_ENABLED',
// TwoFactorSetupRequired: 'TWO_FACTOR_SETUP_REQUIRED',
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
// IncorrectPassword: 'INCORRECT_PASSWORD',
// MissingEncryptionKey: 'MISSING_ENCRYPTION_KEY',
// MissingBackupCode: 'MISSING_BACKUP_CODE',
} as const;
export type AuthenticationErrorCode =
// eslint-disable-next-line @typescript-eslint/ban-types
(typeof AuthenticationErrorCode)[keyof typeof AuthenticationErrorCode] | (string & {});

View File

@ -0,0 +1,105 @@
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import {
formatSecureCookieName,
getCookieDomain,
useSecureCookies,
} from '@documenso/lib/constants/auth';
import { appLog } from '@documenso/lib/utils/debugger';
import { env } from '@documenso/lib/utils/env';
import { AUTH_SESSION_LIFETIME } from '../../config';
import { extractCookieFromHeaders } from '../utils/cookies';
import { generateSessionToken } from './session';
export const sessionCookieName = formatSecureCookieName('sessionId');
export const csrfCookieName = formatSecureCookieName('csrfToken');
const getAuthSecret = () => {
const authSecret = env('NEXTAUTH_SECRET');
if (!authSecret) {
throw new Error('NEXTAUTH_SECRET is not set');
}
return authSecret;
};
/**
* Generic auth session cookie options.
*/
export const sessionCookieOptions = {
httpOnly: true,
path: '/',
sameSite: useSecureCookies ? 'none' : 'lax',
secure: useSecureCookies,
domain: getCookieDomain(),
expires: new Date(Date.now() + AUTH_SESSION_LIFETIME),
} as const;
export const extractSessionCookieFromHeaders = (headers: Headers): string | null => {
return extractCookieFromHeaders(sessionCookieName, headers);
};
/**
* Get the session cookie attached to the request headers.
*
* @param c - The Hono context.
* @returns The session ID or null if no session cookie is found.
*/
export const getSessionCookie = async (c: Context): Promise<string | null> => {
const sessionId = await getSignedCookie(c, getAuthSecret(), sessionCookieName);
return sessionId || null;
};
/**
* Set the session cookie into the Hono context.
*
* @param c - The Hono context.
* @param sessionToken - The session token to set.
*/
export const setSessionCookie = async (c: Context, sessionToken: string) => {
await setSignedCookie(
c,
sessionCookieName,
sessionToken,
getAuthSecret(),
sessionCookieOptions,
).catch((err) => {
appLog('SetSessionCookie', `Error setting signed cookie: ${err}`);
throw err;
});
};
/**
* Set the session cookie into the Hono context.
*
* @param c - The Hono context.
* @param sessionToken - The session token to set.
*/
export const deleteSessionCookie = (c: Context) => {
deleteCookie(c, sessionCookieName, sessionCookieOptions);
};
export const getCsrfCookie = async (c: Context) => {
const csrfToken = await getSignedCookie(c, getAuthSecret(), csrfCookieName);
return csrfToken || null;
};
export const setCsrfCookie = async (c: Context) => {
const csrfToken = generateSessionToken();
await setSignedCookie(c, csrfCookieName, csrfToken, getAuthSecret(), {
...sessionCookieOptions,
// Explicity set to undefined for session lived cookie.
expires: undefined,
maxAge: undefined,
});
return csrfToken;
};

View File

@ -0,0 +1,150 @@
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { type Session, type User, UserSecurityAuditLogType } from '@prisma/client';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { AUTH_SESSION_LIFETIME } from '../../config';
/**
* The user object to pass around the app.
*
* Do not put anything sensitive in here since it will be public.
*/
export type SessionUser = Pick<
User,
| 'id'
| 'name'
| 'email'
| 'emailVerified'
| 'avatarImageId'
| 'twoFactorEnabled'
| 'roles'
| 'signature'
| 'url'
| 'customerId'
>;
export type SessionValidationResult =
| {
session: Session;
user: SessionUser;
isAuthenticated: true;
}
| { session: null; user: null; isAuthenticated: false };
export const generateSessionToken = (): string => {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
};
export const createSession = async (
token: string,
userId: number,
metadata: RequestMetadata,
): Promise<Session> => {
const hashedSessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: Session = {
id: hashedSessionId,
sessionToken: hashedSessionId,
userId,
updatedAt: new Date(),
createdAt: new Date(),
expiresAt: new Date(Date.now() + AUTH_SESSION_LIFETIME),
ipAddress: metadata.ipAddress ?? null,
userAgent: metadata.userAgent ?? null,
};
await prisma.session.create({
data: session,
});
await prisma.userSecurityAuditLog.create({
data: {
userId,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN,
},
});
return session;
};
export const validateSessionToken = async (token: string): Promise<SessionValidationResult> => {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const result = await prisma.session.findUnique({
where: {
id: sessionId,
},
include: {
user: {
/**
* Do not expose anything sensitive here.
*/
select: {
id: true,
name: true,
email: true,
emailVerified: true,
avatarImageId: true,
twoFactorEnabled: true,
roles: true,
signature: true,
url: true,
customerId: true,
},
},
},
});
if (!result?.user) {
return { session: null, user: null, isAuthenticated: false };
}
const { user, ...session } = result;
if (Date.now() >= session.expiresAt.getTime()) {
await prisma.session.delete({ where: { id: sessionId } });
return { session: null, user: null, isAuthenticated: false };
}
if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
await prisma.session.update({
where: {
id: session.id,
},
data: {
expiresAt: session.expiresAt,
},
});
}
return { session, user, isAuthenticated: true };
};
export const invalidateSession = async (
sessionId: string,
metadata: RequestMetadata,
): Promise<void> => {
const session = await prisma.session.delete({ where: { id: sessionId } });
await prisma.userSecurityAuditLog.create({
data: {
userId: session.userId,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
type: UserSecurityAuditLogType.SIGN_OUT,
},
});
};

View File

@ -0,0 +1,22 @@
import type { Context } from 'hono';
import type { HonoAuthContext } from '../../types/context';
import { createSession, generateSessionToken } from '../session/session';
import { setSessionCookie } from '../session/session-cookies';
type AuthorizeUser = {
userId: number;
};
/**
* Handles creating a session.
*/
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
const metadata = c.get('requestMetadata');
const sessionToken = generateSessionToken();
await createSession(sessionToken, user.userId, metadata);
await setSessionCookie(c, sessionToken);
};

View File

@ -0,0 +1,14 @@
/**
* Todo: Use library for cookies instead.
*/
export const extractCookieFromHeaders = (cookieName: string, headers: Headers): string | null => {
const cookieHeader = headers.get('cookie') || '';
const cookiePairs = cookieHeader.split(';');
const cookie = cookiePairs.find((pair) => pair.trim().startsWith(cookieName));
if (!cookie) {
return null;
}
return cookie.split('=')[1].trim();
};

View File

@ -0,0 +1,56 @@
import type { Context } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error';
import { AuthenticationErrorCode } from '../errors/error-codes';
import type { SessionValidationResult } from '../session/session';
import { validateSessionToken } from '../session/session';
import { getSessionCookie } from '../session/session-cookies';
export const getSession = async (c: Context | Request) => {
const { session, user } = await getOptionalSession(mapRequestToContextForCookie(c));
if (session && user) {
return { session, user };
}
if (c instanceof Request) {
throw new Error('Unauthorized');
}
throw new AppError(AuthenticationErrorCode.Unauthorized);
};
export const getOptionalSession = async (
c: Context | Request,
): Promise<SessionValidationResult> => {
const sessionId = await getSessionCookie(mapRequestToContextForCookie(c));
if (!sessionId) {
return {
isAuthenticated: false,
session: null,
user: null,
};
}
return await validateSessionToken(sessionId);
};
/**
* Todo: (RR7) Rethink, this is pretty sketchy.
*/
const mapRequestToContextForCookie = (c: Context | Request) => {
if (c instanceof Request) {
const partialContext = {
req: {
raw: c,
},
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return partialContext as unknown as Context;
}
return c;
};

View File

@ -0,0 +1,86 @@
import { CodeChallengeMethod, OAuth2Client, generateCodeVerifier, generateState } from 'arctic';
import type { Context } from 'hono';
import { setCookie } from 'hono/cookie';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { OAuthClientOptions } from '../../config';
import { sessionCookieOptions } from '../session/session-cookies';
import { getOpenIdConfiguration } from './open-id';
type HandleOAuthAuthorizeUrlOptions = {
/**
* Hono context.
*/
c: Context;
/**
* OAuth client options.
*/
clientOptions: OAuthClientOptions;
/**
* Optional redirect path to redirect the user somewhere on the app after authorization.
*/
redirectPath?: string;
};
const oauthCookieMaxAge = 60 * 10; // 10 minutes.
export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => {
const { c, clientOptions, redirectPath } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const { authorization_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
requiredScopes: clientOptions.scope,
});
const oAuthClient = new OAuth2Client(
clientOptions.clientId,
clientOptions.clientSecret,
clientOptions.redirectUrl,
);
const scopes = clientOptions.scope;
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = oAuthClient.createAuthorizationURLWithPKCE(
authorization_endpoint,
state,
CodeChallengeMethod.S256,
codeVerifier,
scopes,
);
// Allow user to select account during login.
url.searchParams.append('prompt', 'login');
setCookie(c, `${clientOptions.id}_oauth_state`, state, {
...sessionCookieOptions,
sameSite: 'lax',
maxAge: oauthCookieMaxAge,
});
setCookie(c, `${clientOptions.id}_code_verifier`, codeVerifier, {
...sessionCookieOptions,
sameSite: 'lax',
maxAge: oauthCookieMaxAge,
});
if (redirectPath) {
setCookie(c, `${clientOptions.id}_redirect_path`, `${state} ${redirectPath}`, {
...sessionCookieOptions,
sameSite: 'lax',
maxAge: oauthCookieMaxAge,
});
}
return c.json({
redirectUrl: url.toString(),
});
};

View File

@ -0,0 +1,195 @@
import { UserSecurityAuditLogType } from '@prisma/client';
import { OAuth2Client, decodeIdToken } from 'arctic';
import type { Context } from 'hono';
import { deleteCookie } from 'hono/cookie';
import { nanoid } from 'nanoid';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { prisma } from '@documenso/prisma';
import type { OAuthClientOptions } from '../../config';
import { AuthenticationErrorCode } from '../errors/error-codes';
import { onAuthorize } from './authorizer';
import { getOpenIdConfiguration } from './open-id';
type HandleOAuthCallbackUrlOptions = {
c: Context;
clientOptions: OAuthClientOptions;
};
export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOptions) => {
const { c, clientOptions } = options;
if (!clientOptions.clientId || !clientOptions.clientSecret) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const { token_endpoint } = await getOpenIdConfiguration(clientOptions.wellKnownUrl, {
requiredScopes: clientOptions.scope,
});
const oAuthClient = new OAuth2Client(
clientOptions.clientId,
clientOptions.clientSecret,
clientOptions.redirectUrl,
);
const requestMeta = c.get('requestMetadata');
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = deleteCookie(c, `${clientOptions.id}_oauth_state`);
const storedCodeVerifier = deleteCookie(c, `${clientOptions.id}_code_verifier`);
const storedRedirectPath = deleteCookie(c, `${clientOptions.id}_redirect_path`) ?? '';
if (!code || !storedState || state !== storedState || !storedCodeVerifier) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid or missing state',
});
}
// eslint-disable-next-line prefer-const
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
if (redirectState !== storedState || !redirectPath) {
redirectPath = '/documents';
}
const tokens = await oAuthClient.validateAuthorizationCode(
token_endpoint,
code,
storedCodeVerifier,
);
const accessToken = tokens.accessToken();
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
const idToken = tokens.idToken();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const claims = decodeIdToken(tokens.idToken()) as Record<string, unknown>;
const email = claims.email;
const name = claims.name;
const sub = claims.sub;
if (typeof email !== 'string' || typeof name !== 'string' || typeof sub !== 'string') {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid claims',
});
}
if (claims.email_verified !== true && !clientOptions.bypassEmailVerification) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
});
}
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
where: {
provider: clientOptions.id,
providerAccountId: sub,
},
include: {
user: true,
},
});
// Directly log in user if account already exists.
if (existingAccount) {
await onAuthorize({ userId: existingAccount.user.id }, c);
return c.redirect(redirectPath, 302);
}
const userWithSameEmail = await prisma.user.findFirst({
where: {
email: email,
},
});
// Handle existing user but no account.
if (userWithSameEmail) {
await prisma.$transaction(async (tx) => {
await tx.account.create({
data: {
type: 'oauth',
provider: clientOptions.id,
providerAccountId: sub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: userWithSameEmail.id,
},
});
// Log link event.
await tx.userSecurityAuditLog.create({
data: {
userId: userWithSameEmail.id,
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
},
});
// If account already exists in an unverified state, remove the password to ensure
// they cannot sign in since we cannot confirm the password was set by the user.
if (!userWithSameEmail.emailVerified) {
await tx.user.update({
where: {
id: userWithSameEmail.id,
},
data: {
emailVerified: new Date(),
password: null,
// Todo: (RR7) Will need to update the "password" account after the migration.
},
});
}
});
await onAuthorize({ userId: userWithSameEmail.id }, c);
return c.redirect(redirectPath, 302);
}
// Handle new user.
const createdUser = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: email,
name: name,
emailVerified: new Date(),
url: nanoid(17),
},
});
await tx.account.create({
data: {
type: 'oauth',
provider: clientOptions.id,
providerAccountId: sub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: user.id,
},
});
return user;
});
await onCreateUserHook(createdUser).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
});
await onAuthorize({ userId: createdUser.id }, c);
return c.redirect(redirectPath, 302);
};

View File

@ -0,0 +1,44 @@
import { z } from 'zod';
const ZOpenIdConfigurationSchema = z.object({
authorization_endpoint: z.string(),
token_endpoint: z.string(),
scopes_supported: z.array(z.string()).optional(),
});
type OpenIdConfiguration = z.infer<typeof ZOpenIdConfigurationSchema>;
type GetOpenIdConfigurationOptions = {
requiredScopes?: string[];
};
export const getOpenIdConfiguration = async (
wellKnownUrl: string,
options: GetOpenIdConfigurationOptions = {},
): Promise<OpenIdConfiguration> => {
const response = await fetch(wellKnownUrl);
if (!response.ok) {
throw new Error(`Failed to fetch OIDC configuration: ${response.statusText}`);
}
const rawConfig = await response.json();
const config = ZOpenIdConfigurationSchema.parse(rawConfig);
// Validate required endpoints
if (!config.authorization_endpoint) {
throw new Error('Missing authorization_endpoint in OIDC configuration');
}
const supportedScopes = config.scopes_supported ?? [];
const requiredScopes = options.requiredScopes ?? [];
const unsupportedScopes = requiredScopes.filter((scope) => !supportedScopes.includes(scope));
if (unsupportedScopes.length > 0) {
throw new Error(`Requested scopes not supported by provider: ${unsupportedScopes.join(', ')}`);
}
return config;
};

View File

@ -0,0 +1,28 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* Handle an optional redirect path.
*/
export const handleRequestRedirect = (redirectUrl?: string) => {
if (!redirectUrl) {
return;
}
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/documents';
} else {
window.location.href = redirectUrl;
}
};
export const handleSignInRedirect = (redirectUrl: string = '/documents') => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/documents';
} else {
window.location.href = redirectUrl;
}
};

View File

@ -0,0 +1,20 @@
import { Hono } from 'hono';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
import type { HonoAuthContext } from '../types/context';
/**
* Have to create this route instead of bundling callback with oauth routes to provide
* backwards compatibility for self-hosters (since we used to use NextAuth).
*/
export const callbackRoute = new Hono<HonoAuthContext>()
/**
* OIDC callback verification.
*/
.get('/oidc', async (c) => handleOAuthCallbackUrl({ c, clientOptions: OidcAuthOptions }))
/**
* Google callback verification.
*/
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }));

View File

@ -0,0 +1,397 @@
import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { Hono } from 'hono';
import { DateTime } from 'luxon';
import { z } from 'zod';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { isTwoFactorAuthenticationEnabled } from '@documenso/lib/server-only/2fa/is-2fa-availble';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-only/user/get-most-recent-verification-token-by-user-id';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { getCsrfCookie } from '../lib/session/session-cookies';
import { onAuthorize } from '../lib/utils/authorizer';
import { getSession } from '../lib/utils/get-session';
import type { HonoAuthContext } from '../types/context';
import {
ZForgotPasswordSchema,
ZResendVerifyEmailSchema,
ZResetPasswordSchema,
ZSignInSchema,
ZSignUpSchema,
ZUpdatePasswordSchema,
ZVerifyEmailSchema,
} from '../types/email-password';
export const emailPasswordRoute = new Hono<HonoAuthContext>()
/**
* Authorize endpoint.
*/
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { email, password, totpCode, backupCode, csrfToken } = c.req.valid('json');
const csrfCookieToken = await getCsrfCookie(c);
// Todo: (RR7) Add logging here.
if (csrfToken !== csrfCookieToken || !csrfCookieToken) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid CSRF token',
});
}
const user = await prisma.user.findFirst({
where: {
email: email.toLowerCase(),
},
});
if (!user || !user.password) {
throw new AppError(AuthenticationErrorCode.InvalidCredentials, {
message: 'Invalid email or password',
});
}
const isPasswordsSame = await compare(password, user.password);
if (!isPasswordsSame) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
},
});
throw new AppError(AuthenticationErrorCode.InvalidCredentials, {
message: 'Invalid email or password',
});
}
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
},
});
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
}
}
if (!user.emailVerified) {
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
userId: user.id,
});
if (
!mostRecentToken ||
mostRecentToken.expires.valueOf() <= Date.now() ||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: user.email,
},
});
}
throw new AppError('UNVERIFIED_EMAIL', {
message: 'Unverified email',
});
}
if (user.disabled) {
throw new AppError('ACCOUNT_DISABLED', {
message: 'Account disabled',
});
}
await onAuthorize({ userId: user.id }, c);
return c.text('', 201);
})
/**
* Signup endpoint.
*/
.post('/signup', sValidator('json', ZSignUpSchema), async (c) => {
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
throw new AppError('SIGNUP_DISABLED', {
message: 'Signups are disabled.',
});
}
const { name, email, password, signature, url } = c.req.valid('json');
if (IS_BILLING_ENABLED() && url && url.length < 6) {
throw new AppError('PREMIUM_PROFILE_URL', {
message: 'Only subscribers can have a username shorter than 6 characters',
});
}
const user = await createUser({ name, email, password, signature, url });
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email: user.email,
},
});
return c.text('OK', 201);
})
/**
* Update password endpoint.
*/
.post('/update-password', sValidator('json', ZUpdatePasswordSchema), async (c) => {
const { password, currentPassword } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
const session = await getSession(c);
await updatePassword({
userId: session.user.id,
password,
currentPassword,
requestMetadata,
});
return c.text('OK', 201);
})
/**
* Verify email endpoint.
*/
.post('/verify-email', sValidator('json', ZVerifyEmailSchema), async (c) => {
const { state, userId } = await verifyEmail({ token: c.req.valid('json').token });
// If email is verified, automatically authenticate user.
if (state === EMAIL_VERIFICATION_STATE.VERIFIED && userId !== null) {
await onAuthorize({ userId }, c);
}
return c.json({
state,
});
})
/**
* Resend verification email endpoint.
*/
.post('/resend-verify-email', sValidator('json', ZResendVerifyEmailSchema), async (c) => {
const { email } = c.req.valid('json');
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',
payload: {
email,
},
});
return c.text('OK', 201);
})
/**
* Forgot password endpoint.
*/
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
const { email } = c.req.valid('json');
await forgotPassword({
email,
});
return c.text('OK', 201);
})
/**
* Reset password endpoint.
*/
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
const { token, password } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
await resetPassword({
token,
password,
requestMetadata,
});
return c.text('OK', 201);
})
/**
* Setup two factor authentication.
*/
.post('/2fa/setup', async (c) => {
const { user } = await getSession(c);
const result = await setupTwoFactorAuthentication({
user,
});
return c.json({
success: true,
secret: result.secret,
uri: result.uri,
});
})
/**
* Enable two factor authentication.
*/
.post(
'/2fa/enable',
sValidator(
'json',
z.object({
code: z.string(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { code } = c.req.valid('json');
const result = await enableTwoFactorAuthentication({
user,
code,
requestMetadata,
});
return c.json({
success: true,
recoveryCodes: result.recoveryCodes,
});
},
)
/**
* Disable two factor authentication.
*/
.post(
'/2fa/disable',
sValidator(
'json',
z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
}),
),
async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { totpCode, backupCode } = c.req.valid('json');
await disableTwoFactorAuthentication({
user,
totpCode,
backupCode,
requestMetadata,
});
return c.text('OK', 201);
},
)
/**
* View backup codes.
*/
.post(
'/2fa/view-recovery-codes',
sValidator(
'json',
z.object({
token: z.string(),
}),
),
async (c) => {
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { token } = c.req.valid('json');
const backupCodes = await viewBackupCodes({
user,
token,
});
return c.json({
success: true,
backupCodes,
});
},
);

View File

@ -0,0 +1,37 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
import type { HonoAuthContext } from '../types/context';
const ZOAuthAuthorizeSchema = z.object({
redirectPath: z.string().optional(),
});
export const oauthRoute = new Hono<HonoAuthContext>()
/**
* Google authorize endpoint.
*/
.post('/authorize/google', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
return handleOAuthAuthorizeUrl({
c,
clientOptions: GoogleAuthOptions,
redirectPath,
});
})
/**
* OIDC authorize endpoint.
*/
.post('/authorize/oidc', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
return handleOAuthAuthorizeUrl({
c,
clientOptions: OidcAuthOptions,
redirectPath,
});
});

View File

@ -0,0 +1,121 @@
import { sValidator } from '@hono/standard-validator';
import { UserSecurityAuditLogType } from '@prisma/client';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import { Hono } from 'hono';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { ZAuthenticationResponseJSONSchema } from '@documenso/lib/types/webauthn';
import { getAuthenticatorOptions } from '@documenso/lib/utils/authenticator';
import { prisma } from '@documenso/prisma';
import { onAuthorize } from '../lib/utils/authorizer';
import type { HonoAuthContext } from '../types/context';
import { ZPasskeyAuthorizeSchema } from '../types/passkey';
export const passkeyRoute = new Hono<HonoAuthContext>()
/**
* Authorize endpoint.
*/
.post('/authorize', sValidator('json', ZPasskeyAuthorizeSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { csrfToken, credential } = c.req.valid('json');
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
try {
const parsedBodyCredential = JSON.parse(credential);
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
} catch {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
const challengeToken = await prisma.anonymousVerificationToken
.delete({
where: {
id: csrfToken,
},
})
.catch(() => null);
if (!challengeToken) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
if (challengeToken.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE);
}
const passkey = await prisma.passkey.findFirst({
where: {
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
emailVerified: true,
},
},
},
});
if (!passkey) {
throw new AppError(AppErrorCode.NOT_SETUP);
}
const user = passkey.user;
const { rpId, origin } = getAuthenticatorOptions();
const verification = await verifyAuthenticationResponse({
response: requestBodyCrediential,
expectedChallenge: challengeToken.token,
expectedOrigin: origin,
expectedRPID: rpId,
authenticator: {
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
counter: Number(passkey.counter),
},
}).catch(() => null);
if (!verification?.verified) {
await prisma.userSecurityAuditLog.create({
data: {
userId: user.id,
ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
},
});
throw new AppError(AppErrorCode.INVALID_REQUEST);
}
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: {
lastUsedAt: new Date(),
counter: verification.authenticationInfo.newCounter,
},
});
await onAuthorize({ userId: user.id }, c);
return c.json(
{
url: '/documents',
},
200,
);
});

View File

@ -0,0 +1,17 @@
import { Hono } from 'hono';
import superjson from 'superjson';
import type { SessionValidationResult } from '../lib/session/session';
import { getOptionalSession } from '../lib/utils/get-session';
export const sessionRoute = new Hono()
.get('/session', async (c) => {
const session: SessionValidationResult = await getOptionalSession(c);
return c.json(session);
})
.get('/session-json', async (c) => {
const session: SessionValidationResult = await getOptionalSession(c);
return c.json(superjson.serialize(session));
});

View File

@ -0,0 +1,27 @@
import { Hono } from 'hono';
import { invalidateSession, validateSessionToken } from '../lib/session/session';
import { deleteSessionCookie, getSessionCookie } from '../lib/session/session-cookies';
import type { HonoAuthContext } from '../types/context';
export const signOutRoute = new Hono<HonoAuthContext>().post('/signout', async (c) => {
const metadata = c.get('requestMetadata');
const sessionId = await getSessionCookie(c);
if (!sessionId) {
return new Response('No session found', { status: 401 });
}
const { session } = await validateSessionToken(sessionId);
if (!session) {
return new Response('No session found', { status: 401 });
}
await invalidateSession(session.id, metadata);
deleteSessionCookie(c);
return c.status(200);
});

View File

@ -0,0 +1,151 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { prisma } from '@documenso/prisma';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { getSession } from '../lib/utils/get-session';
import type { HonoAuthContext } from '../types/context';
import {
ZDisableTwoFactorRequestSchema,
ZEnableTwoFactorRequestSchema,
ZViewTwoFactorRecoveryCodesRequestSchema,
} from './two-factor.types';
export const twoFactorRoute = new Hono<HonoAuthContext>()
/**
* Setup two factor authentication.
*/
.post('/setup', async (c) => {
const { user } = await getSession(c);
const result = await setupTwoFactorAuthentication({
user,
});
return c.json({
success: true,
secret: result.secret,
uri: result.uri,
});
})
/**
* Enable two factor authentication.
*/
.post('/enable', sValidator('json', ZEnableTwoFactorRequestSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { code } = c.req.valid('json');
const result = await enableTwoFactorAuthentication({
user,
code,
requestMetadata,
});
return c.json({
success: true,
recoveryCodes: result.recoveryCodes,
});
})
/**
* Disable two factor authentication.
*/
.post('/disable', sValidator('json', ZDisableTwoFactorRequestSchema), async (c) => {
const requestMetadata = c.get('requestMetadata');
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { totpCode, backupCode } = c.req.valid('json');
await disableTwoFactorAuthentication({
user,
totpCode,
backupCode,
requestMetadata,
});
return c.text('OK', 201);
})
/**
* View backup codes.
*/
.post(
'/view-recovery-codes',
sValidator('json', ZViewTwoFactorRecoveryCodesRequestSchema),
async (c) => {
const { user: sessionUser } = await getSession(c);
const user = await prisma.user.findFirst({
where: {
id: sessionUser.id,
},
select: {
id: true,
email: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new AppError(AuthenticationErrorCode.InvalidRequest);
}
const { token } = c.req.valid('json');
const backupCodes = await viewBackupCodes({
user,
token,
});
return c.json({
success: true,
backupCodes,
});
},
);

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
export const ZEnableTwoFactorRequestSchema = z.object({
code: z.string().min(6).max(6),
});
export type TEnableTwoFactorRequestSchema = z.infer<typeof ZEnableTwoFactorRequestSchema>;
export const ZDisableTwoFactorRequestSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TDisableTwoFactorRequestSchema = z.infer<typeof ZDisableTwoFactorRequestSchema>;
export const ZViewTwoFactorRecoveryCodesRequestSchema = z.object({
token: z.string().trim().min(1),
});
export type TViewTwoFactorRecoveryCodesRequestSchema = z.infer<
typeof ZViewTwoFactorRecoveryCodesRequestSchema
>;

View File

@ -0,0 +1,7 @@
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
export type HonoAuthContext = {
Variables: {
requestMetadata: RequestMetadata;
};
};

View File

@ -0,0 +1,83 @@
import { z } from 'zod';
export const ZCurrentPasswordSchema = z
.string()
.min(6, { message: 'Must be at least 6 characters in length' })
.max(72);
export const ZSignInSchema = z.object({
email: z.string().email().min(1),
password: ZCurrentPasswordSchema,
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
csrfToken: z.string().trim(),
});
export type TSignInSchema = z.infer<typeof ZSignInSchema>;
export const ZPasswordSchema = z
.string()
.min(8, { message: 'Must be at least 8 characters in length' })
.max(72, { message: 'Cannot be more than 72 characters in length' })
.refine((value) => value.length > 25 || /[A-Z]/.test(value), {
message: 'One uppercase character',
})
.refine((value) => value.length > 25 || /[a-z]/.test(value), {
message: 'One lowercase character',
})
.refine((value) => value.length > 25 || /\d/.test(value), {
message: 'One number',
})
.refine((value) => value.length > 25 || /[`~<>?,./!@#$%^&*()\-_"'+=|{}[\];:\\]/.test(value), {
message: 'One special character is required',
});
export const ZSignUpSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: ZPasswordSchema,
signature: z.string().nullish(),
url: z
.string()
.trim()
.toLowerCase()
.min(1)
.regex(/^[a-z0-9-]+$/, {
message: 'Username can only container alphanumeric characters and dashes.',
})
.optional(),
});
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
export const ZForgotPasswordSchema = z.object({
email: z.string().email().min(1),
});
export type TForgotPasswordSchema = z.infer<typeof ZForgotPasswordSchema>;
export const ZResetPasswordSchema = z.object({
password: ZPasswordSchema,
token: z.string().min(1),
});
export type TResetPasswordSchema = z.infer<typeof ZResetPasswordSchema>;
export const ZVerifyEmailSchema = z.object({
token: z.string().min(1),
});
export type TVerifyEmailSchema = z.infer<typeof ZVerifyEmailSchema>;
export const ZResendVerifyEmailSchema = z.object({
email: z.string().email().min(1),
});
export type TResendVerifyEmailSchema = z.infer<typeof ZResendVerifyEmailSchema>;
export const ZUpdatePasswordSchema = z.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
});
export type TUpdatePasswordSchema = z.infer<typeof ZUpdatePasswordSchema>;

View File

@ -0,0 +1,8 @@
import { z } from 'zod';
export const ZPasskeyAuthorizeSchema = z.object({
csrfToken: z.string().min(1),
credential: z.string().min(1),
});
export type TPasskeyAuthorizeSchema = z.infer<typeof ZPasskeyAuthorizeSchema>;

View File

@ -0,0 +1,8 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"strict": true
}
}

View File

@ -17,8 +17,6 @@
"@documenso/prisma": "*",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "14.2.6",
"next-auth": "4.24.5",
"react": "^18",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"

View File

@ -1,4 +1,4 @@
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants';
import type { TLimitsResponseSchema } from './schema';
@ -12,7 +12,7 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL('/api/limits', APP_BASE_URL() ?? 'http://localhost:3000');
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());
if (teamId) {
requestHeaders['team-id'] = teamId.toString();

View File

@ -1,20 +1,15 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getToken } from 'next-auth/jwt';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { ERROR_CODES } from './errors';
import type { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
import { getServerLimits } from './server';
export const limitsHandler = async (
req: NextApiRequest,
res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
) => {
export const limitsHandler = async (req: Request) => {
try {
const token = await getToken({ req });
const { user } = await getSession(req);
const rawTeamId = req.headers['team-id'];
const rawTeamId = req.headers.get('team-id');
let teamId: number | null = null;
@ -26,9 +21,11 @@ export const limitsHandler = async (
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
}
const limits = await getServerLimits({ email: token?.email, teamId });
const limits = await getServerLimits({ email: user.email, teamId });
return res.status(200).json(limits);
return Response.json(limits, {
status: 200,
});
} catch (err) {
console.error('error', err);
@ -37,13 +34,23 @@ export const limitsHandler = async (
.with(ERROR_CODES.UNAUTHORIZED, () => 401)
.otherwise(() => 500);
return res.status(status).json({
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
});
return Response.json(
{
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
},
{
status,
},
);
}
return res.status(500).json({
error: ERROR_CODES.UNKNOWN,
});
return Response.json(
{
error: ERROR_CODES.UNKNOWN,
},
{
status: 500,
},
);
}
};

View File

@ -1,5 +1,3 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { isDeepEqual } from 'remeda';

View File

@ -1,23 +0,0 @@
'use server';
import { headers } from 'next/headers';
import { getLimits } from '../client';
import { LimitsProvider as ClientLimitsProvider } from './client';
export type LimitsProviderProps = {
children?: React.ReactNode;
teamId?: number;
};
export const LimitsProvider = async ({ children, teamId }: LimitsProviderProps) => {
const requestHeaders = Object.fromEntries(headers().entries());
const limits = await getLimits({ headers: requestHeaders, teamId });
return (
<ClientLimitsProvider initialValue={limits} teamId={teamId}>
{children}
</ClientLimitsProvider>
);
};

View File

@ -1,8 +1,8 @@
import { DocumentSource, SubscriptionStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { DocumentSource, SubscriptionStatus } from '@documenso/prisma/client';
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
@ -11,7 +11,7 @@ import type { TLimitsResponseSchema } from './schema';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
email?: string | null;
email: string;
teamId?: number | null;
};

View File

@ -1,5 +1,3 @@
'use server';
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
@ -17,8 +15,6 @@ export const getCheckoutSession = async ({
returnUrl,
subscriptionMetadata,
}: GetCheckoutSessionOptions) => {
'use server';
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',

View File

@ -1,7 +1,8 @@
import type { User } from '@prisma/client';
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
@ -31,7 +32,9 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
*
* Will create a Stripe customer and update the relevant user if one does not exist.
*/
export const getStripeCustomerByUser = async (user: User) => {
export const getStripeCustomerByUser = async (
user: Pick<User, 'id' | 'customerId' | 'email' | 'name'>,
) => {
if (user.customerId) {
const stripeCustomer = await getStripeCustomerById(user.customerId);

View File

@ -1,5 +1,3 @@
'use server';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetPortalSessionOptions = {
@ -8,8 +6,6 @@ export type GetPortalSessionOptions = {
};
export const getPortalSession = async ({ customerId, returnUrl }: GetPortalSessionOptions) => {
'use server';
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,

View File

@ -4,15 +4,14 @@ import { stripe } from '@documenso/lib/server-only/stripe';
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes = typeof plan === 'string' ? [plan] : plan;
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
const { data: prices } = await stripe.prices.search({
query,
const prices = await stripe.prices.list({
expand: ['data.product'],
limit: 100,
});
return prices.filter((price) => price.type === 'recurring');
return prices.data.filter(
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
);
};

View File

@ -1,10 +1,10 @@
import { type Subscription, type Team, type User } from '@prisma/client';
import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getTeamPrices } from './get-team-prices';

View File

@ -1,13 +1,11 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { onSubscriptionDeleted } from './on-subscription-deleted';
@ -18,39 +16,56 @@ type StripeWebhookResponse = {
message: string;
};
export const stripeWebhookHandler = async (
req: NextApiRequest,
res: NextApiResponse<StripeWebhookResponse>,
) => {
export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
try {
const isBillingEnabled = await getFlag('app_billing');
const isBillingEnabled = IS_BILLING_ENABLED();
const webhookSecret = env('NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET');
if (!webhookSecret) {
throw new Error('Missing Stripe webhook secret');
}
if (!isBillingEnabled) {
return res.status(500).json({
success: false,
message: 'Billing is disabled',
});
return Response.json(
{
success: false,
message: 'Billing is disabled',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const signature =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
typeof req.headers.get('stripe-signature') === 'string'
? req.headers.get('stripe-signature')
: '';
if (!signature) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
return Response.json(
{
success: false,
message: 'No signature found in request',
} satisfies StripeWebhookResponse,
{ status: 400 },
);
}
const body = await buffer(req);
const payload = await req.text();
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
);
if (!payload) {
return Response.json(
{
success: false,
message: 'No payload found in request',
} satisfies StripeWebhookResponse,
{ status: 400 },
);
}
await match(event.type)
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
return await match(event.type)
.with('checkout.session.completed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session;
@ -92,10 +107,10 @@ export const stripeWebhookHandler = async (
: session.subscription?.id;
if (!subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid session',
});
return Response.json(
{ success: false, message: 'Invalid session' } satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
@ -104,26 +119,29 @@ export const stripeWebhookHandler = async (
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
await handleTeamSeatCheckout({ subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
{ status: 200 },
);
}
// Validate user ID.
if (!userId || Number.isNaN(userId)) {
return res.status(500).json({
success: false,
message: 'Invalid session or missing user ID',
});
return Response.json(
{
success: false,
message: 'Invalid session or missing user ID',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
{ status: 200 },
);
})
.with('customer.subscription.updated', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@ -142,18 +160,21 @@ export const stripeWebhookHandler = async (
});
if (!team) {
return res.status(500).json({
success: false,
message: 'No team associated with subscription found',
});
return Response.json(
{
success: false,
message: 'No team associated with subscription found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const result = await prisma.user.findFirst({
@ -166,28 +187,37 @@ export const stripeWebhookHandler = async (
});
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
return Response.json(
{
success: false,
message: 'User not found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
})
.with('invoice.payment_succeeded', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason !== 'subscription_cycle') {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const customerId =
@ -199,19 +229,25 @@ export const stripeWebhookHandler = async (
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
return Response.json(
{
success: false,
message: 'Invalid invoice',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status === 'incomplete_expired') {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
@ -222,18 +258,24 @@ export const stripeWebhookHandler = async (
});
if (!team) {
return res.status(500).json({
success: false,
message: 'No team associated with subscription found',
});
return Response.json(
{
success: false,
message: 'No team associated with subscription found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const result = await prisma.user.findFirst({
@ -246,18 +288,24 @@ export const stripeWebhookHandler = async (
});
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
return Response.json(
{
success: false,
message: 'User not found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
})
.with('invoice.payment_failed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@ -272,19 +320,25 @@ export const stripeWebhookHandler = async (
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
return Response.json(
{
success: false,
message: 'Invalid invoice',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.status === 'incomplete_expired') {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
@ -295,18 +349,24 @@ export const stripeWebhookHandler = async (
});
if (!team) {
return res.status(500).json({
success: false,
message: 'No team associated with subscription found',
});
return Response.json(
{
success: false,
message: 'No team associated with subscription found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const result = await prisma.user.findFirst({
@ -319,18 +379,24 @@ export const stripeWebhookHandler = async (
});
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
return Response.json(
{
success: false,
message: 'User not found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
})
.with('customer.subscription.deleted', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@ -338,24 +404,33 @@ export const stripeWebhookHandler = async (
await onSubscriptionDeleted({ subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
})
.otherwise(() => {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
});
} catch (err) {
console.error(err);
res.status(500).json({
success: false,
message: 'Unknown error',
});
return Response.json(
{
success: false,
message: 'Unknown error',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
};

View File

@ -1,6 +1,7 @@
import { SubscriptionStatus } from '@prisma/client';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionDeletedOptions = {
subscription: Stripe.Subscription;

View File

@ -1,9 +1,9 @@
import type { Prisma } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionUpdatedOptions = {
userId?: number;

View File

@ -0,0 +1,57 @@
import type { Subscription } from '@prisma/client';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
export type IsCommunityPlanOptions = {
userId: number;
teamId?: number;
};
/**
* Whether the user or team is on the community plan.
*/
export const isCommunityPlan = async ({
userId,
teamId,
}: IsCommunityPlanOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
subscriptions: true,
},
},
},
})
.then((team) => team.owner.subscriptions);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
subscriptions: true,
},
})
.then((user) => user.subscriptions);
}
if (subscriptions.length === 0) {
return false;
}
const communityPlanPriceIds = await getCommunityPlanPriceIds();
return subscriptionsContainsActivePlan(subscriptions, communityPlanPriceIds);
};

View File

@ -1,7 +1,8 @@
import type { Subscription } from '@prisma/client';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Subscription } from '@documenso/prisma/client';
import { getEnterprisePlanPriceIds } from '../stripe/get-enterprise-plan-prices';

View File

@ -1,7 +1,8 @@
import type { Document, Subscription } from '@prisma/client';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Document, Subscription } from '@documenso/prisma/client';
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';

View File

@ -1 +0,0 @@
declare module '@documenso/tailwind-config';

View File

@ -1,17 +1,17 @@
export * from '@react-email/body';
export * from '@react-email/button';
export * from '@react-email/column';
export * from '@react-email/container';
export * from '@react-email/font';
export * from '@react-email/head';
export * from '@react-email/heading';
export * from '@react-email/hr';
export * from '@react-email/html';
export * from '@react-email/img';
export * from '@react-email/link';
export * from '@react-email/preview';
export * from '@react-email/render';
export * from '@react-email/row';
export * from '@react-email/section';
export * from '@react-email/tailwind';
export * from '@react-email/text';
export { Body } from '@react-email/body';
export { Button } from '@react-email/button';
export { Column } from '@react-email/column';
export { Container } from '@react-email/container';
export { Font } from '@react-email/font';
export { Head } from '@react-email/head';
export { Heading } from '@react-email/heading';
export { Hr } from '@react-email/hr';
export { Html } from '@react-email/html';
export { Img } from '@react-email/img';
export { Link } from '@react-email/link';
export { Preview } from '@react-email/preview';
export { render } from '@react-email/render';
export { Row } from '@react-email/row';
export { Section } from '@react-email/section';
export { Tailwind } from '@react-email/tailwind';
export { Text } from '@react-email/text';

View File

@ -1,6 +1,7 @@
import type { Transporter } from 'nodemailer';
import { createTransport } from 'nodemailer';
import { env } from '@documenso/lib/utils/env';
import { ResendTransport } from '@documenso/nodemailer-resend';
import { MailChannelsTransport } from './transports/mailchannels';
@ -51,13 +52,13 @@ import { MailChannelsTransport } from './transports/mailchannels';
* - `NEXT_PRIVATE_SMTP_SERVICE` is optional and used specifically for well-known services like Gmail.
*/
const getTransport = (): Transporter => {
const transport = process.env.NEXT_PRIVATE_SMTP_TRANSPORT ?? 'smtp-auth';
const transport = env('NEXT_PRIVATE_SMTP_TRANSPORT') ?? 'smtp-auth';
if (transport === 'mailchannels') {
return createTransport(
MailChannelsTransport.makeTransport({
apiKey: process.env.NEXT_PRIVATE_MAILCHANNELS_API_KEY,
endpoint: process.env.NEXT_PRIVATE_MAILCHANNELS_ENDPOINT,
apiKey: env('NEXT_PRIVATE_MAILCHANNELS_API_KEY'),
endpoint: env('NEXT_PRIVATE_MAILCHANNELS_ENDPOINT'),
}),
);
}
@ -65,43 +66,41 @@ const getTransport = (): Transporter => {
if (transport === 'resend') {
return createTransport(
ResendTransport.makeTransport({
apiKey: process.env.NEXT_PRIVATE_RESEND_API_KEY || '',
apiKey: env('NEXT_PRIVATE_RESEND_API_KEY') || '',
}),
);
}
if (transport === 'smtp-api') {
if (!process.env.NEXT_PRIVATE_SMTP_HOST || !process.env.NEXT_PRIVATE_SMTP_APIKEY) {
if (!env('NEXT_PRIVATE_SMTP_HOST') || !env('NEXT_PRIVATE_SMTP_APIKEY')) {
throw new Error(
'SMTP API transport requires NEXT_PRIVATE_SMTP_HOST and NEXT_PRIVATE_SMTP_APIKEY',
);
}
return createTransport({
host: process.env.NEXT_PRIVATE_SMTP_HOST,
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
host: env('NEXT_PRIVATE_SMTP_HOST'),
port: Number(env('NEXT_PRIVATE_SMTP_PORT')) || 587,
secure: env('NEXT_PRIVATE_SMTP_SECURE') === 'true',
auth: {
user: process.env.NEXT_PRIVATE_SMTP_APIKEY_USER ?? 'apikey',
pass: process.env.NEXT_PRIVATE_SMTP_APIKEY ?? '',
user: env('NEXT_PRIVATE_SMTP_APIKEY_USER') ?? 'apikey',
pass: env('NEXT_PRIVATE_SMTP_APIKEY') ?? '',
},
});
}
return createTransport({
host: process.env.NEXT_PRIVATE_SMTP_HOST ?? 'localhost:2500',
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
ignoreTLS: process.env.NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS === 'true',
auth: process.env.NEXT_PRIVATE_SMTP_USERNAME
host: env('NEXT_PRIVATE_SMTP_HOST') ?? '127.0.0.1:2500',
port: Number(env('NEXT_PRIVATE_SMTP_PORT')) || 587,
secure: env('NEXT_PRIVATE_SMTP_SECURE') === 'true',
ignoreTLS: env('NEXT_PRIVATE_SMTP_UNSAFE_IGNORE_TLS') === 'true',
auth: env('NEXT_PRIVATE_SMTP_USERNAME')
? {
user: process.env.NEXT_PRIVATE_SMTP_USERNAME,
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
user: env('NEXT_PRIVATE_SMTP_USERNAME'),
pass: env('NEXT_PRIVATE_SMTP_PASSWORD') ?? '',
}
: undefined,
...(process.env.NEXT_PRIVATE_SMTP_SERVICE
? { service: process.env.NEXT_PRIVATE_SMTP_SERVICE }
: {}),
...(env('NEXT_PRIVATE_SMTP_SERVICE') ? { service: env('NEXT_PRIVATE_SMTP_SERVICE') } : {}),
});
};

View File

@ -17,6 +17,7 @@
"worker:test": "tsup worker/index.ts --format esm"
},
"dependencies": {
"@documenso/tailwind-config": "*",
"@documenso/nodemailer-resend": "2.0.0",
"@react-email/body": "0.0.4",
"@react-email/button": "0.0.11",
@ -35,12 +36,11 @@
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
"nodemailer": "^6.9.9",
"react-email": "^1.9.5",
"resend": "^2.0.0"
"nodemailer": "6.9.9",
"react-email": "1.9.5",
"resend": "2.0.0"
},
"devDependencies": {
"@documenso/tailwind-config": "*",
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.14",
"tsup": "^7.1.0"

View File

@ -1,5 +1,3 @@
'use client';
import { createContext, useContext } from 'react';
type BrandingContextValue = {

View File

@ -9,6 +9,9 @@ export type RenderOptions = ReactEmail.Options & {
branding?: BrandingSettings;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const colors = (config.theme?.extend?.colors || {}) as Record<string, string>;
export const render = (element: React.ReactNode, options?: RenderOptions) => {
const { branding, ...otherOptions } = options ?? {};
@ -17,7 +20,7 @@ export const render = (element: React.ReactNode, options?: RenderOptions) => {
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
colors,
},
},
}}
@ -36,7 +39,7 @@ export const renderAsync = async (element: React.ReactNode, options?: RenderOpti
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
colors,
},
},
}}

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@ -8,12 +8,14 @@ export interface TemplateDocumentCancelProps {
inviterEmail: string;
documentName: string;
assetBaseUrl: string;
cancellationReason?: string;
}
export const TemplateDocumentCancel = ({
inviterName,
documentName,
assetBaseUrl,
cancellationReason,
}: TemplateDocumentCancelProps) => {
return (
<>
@ -34,6 +36,12 @@ export const TemplateDocumentCancel = ({
<Text className="my-1 text-center text-base text-slate-400">
<Trans>You don't need to sign it anymore.</Trans>
</Text>
{cancellationReason && (
<Text className="mt-4 text-center text-base">
<Trans>Reason for cancellation: {cancellationReason}</Trans>
</Text>
)}
</Section>
</>
);

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Button, Column, Img, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';

View File

@ -1,11 +1,11 @@
import { useMemo } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { RecipientRole } from '@documenso/prisma/client';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@ -40,11 +40,9 @@ export const TemplateDocumentInvite = ({
const rejectDocumentLink = useMemo(() => {
const url = new URL(signDocumentLink);
url.searchParams.set('reject', 'true');
return url.toString();
}, []);
}, [signDocumentLink]);
return (
<>
@ -52,31 +50,32 @@ export const TemplateDocumentInvite = ({
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{selfSigner ? (
<Trans>
Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
) : isTeamInvite ? (
<>
{includeSenderDetails ? (
<Trans>
{inviterName} on behalf of "{teamName}" has invited you to{' '}
{_(actionVerb).toLowerCase()}
</Trans>
) : (
<Trans>
{teamName} has invited you to {_(actionVerb).toLowerCase()}
</Trans>
)}
<br />"{documentName}"
</>
) : (
<Trans>
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
)}
{match({ selfSigner, isTeamInvite, includeSenderDetails, teamName })
.with({ selfSigner: true }, () => (
<Trans>
Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
))
.with({ isTeamInvite: true, includeSenderDetails: true, teamName: P.string }, () => (
<Trans>
{inviterName} on behalf of "{teamName}" has invited you to{' '}
{_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
))
.with({ isTeamInvite: true, teamName: P.string }, () => (
<Trans>
{teamName} has invited you to {_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
))
.otherwise(() => (
<Trans>
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
))}
</Text>
<Text className="my-1 text-center text-base text-slate-400">
@ -85,6 +84,9 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Continue by approving the document.</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => (
<Trans>Continue by assisting with the document.</Trans>
))
.exhaustive()}
</Text>
@ -105,6 +107,7 @@ export const TemplateDocumentInvite = ({
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.CC, () => '')
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.exhaustive()}
</Button>
</Section>

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Column, Img, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Column, Img, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Button, Heading, Text } from '../components';

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Container, Heading, Section, Text } from '../components';

View File

@ -1,5 +1,6 @@
import { Trans } from '@lingui/macro';
import { env } from 'next-runtime-env';
import { Trans } from '@lingui/react/macro';
import { env } from '@documenso/lib/utils/env';
import { Button, Column, Img, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Link, Section, Text } from '../components';
import { useBranding } from '../providers/branding';

View File

@ -1,4 +1,4 @@
import { Trans } from '@lingui/macro';
import { Trans } from '@lingui/react/macro';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';

View File

@ -1,5 +1,6 @@
import { Trans } from '@lingui/macro';
import { env } from 'next-runtime-env';
import { Trans } from '@lingui/react/macro';
import { env } from '@documenso/lib/utils/env';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';

View File

@ -0,0 +1,91 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Preview, Section, Text } from '../components';
import { TemplateFooter } from '../template-components/template-footer';
export interface BulkSendCompleteEmailProps {
userName: string;
templateName: string;
totalProcessed: number;
successCount: number;
failedCount: number;
errors: string[];
assetBaseUrl?: string;
}
export const BulkSendCompleteEmail = ({
userName,
templateName,
totalProcessed,
successCount,
failedCount,
errors,
}: BulkSendCompleteEmailProps) => {
const { _ } = useLingui();
return (
<Html>
<Head />
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
<Body className="mx-auto my-auto bg-white font-sans">
<Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section>
<Text className="text-sm">
<Trans>Hi {userName},</Trans>
</Text>
<Text className="text-sm">
<Trans>Your bulk send operation for template "{templateName}" has completed.</Trans>
</Text>
<Text className="text-lg font-semibold">
<Trans>Summary:</Trans>
</Text>
<ul className="my-2 ml-4 list-inside list-disc">
<li>
<Trans>Total rows processed: {totalProcessed}</Trans>
</li>
<li className="mt-1">
<Trans>Successfully created: {successCount}</Trans>
</li>
<li className="mt-1">
<Trans>Failed: {failedCount}</Trans>
</li>
</ul>
{failedCount > 0 && (
<Section className="mt-4">
<Text className="text-lg font-semibold">
<Trans>The following errors occurred:</Trans>
</Text>
<ul className="my-2 ml-4 list-inside list-disc">
{errors.map((error, index) => (
<li key={index} className="text-destructive mt-1 text-sm text-slate-400">
{error}
</li>
))}
</ul>
</Section>
)}
<Text className="text-sm">
<Trans>
You can view the created documents in your dashboard under the "Documents created
from template" section.
</Trans>
</Text>
</Section>
</Container>
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};

View File

@ -1,4 +1,4 @@
import { msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';

View File

@ -1,5 +1,6 @@
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { formatTeamUrl } from '@documenso/lib/utils/teams';

View File

@ -1,4 +1,4 @@
import { msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components';
@ -14,6 +14,7 @@ export const DocumentCancelTemplate = ({
inviterEmail = 'lucas@documenso.com',
documentName = 'Open Source Pledge.pdf',
assetBaseUrl = 'http://localhost:3002',
cancellationReason,
}: DocumentCancelEmailTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
@ -48,6 +49,7 @@ export const DocumentCancelTemplate = ({
inviterEmail={inviterEmail}
documentName={documentName}
assetBaseUrl={assetBaseUrl}
cancellationReason={cancellationReason}
/>
</Section>
</Container>

View File

@ -1,4 +1,4 @@
import { msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Html, Img, Preview, Section } from '../components';

Some files were not shown because too many files have changed in this diff Show More