feat: migrate nextjs to rr7

This commit is contained in:
David Nguyen
2025-01-02 15:33:37 +11:00
parent 9183f668d3
commit 383b5f78f0
898 changed files with 31175 additions and 24615 deletions

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

@ -0,0 +1,35 @@
import { 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: Check methods. Are these get/post/update requests?
// Todo: Is there really no validations?
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: {},
});
});

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,4 @@
import { createNextRoute } from '@ts-rest/next';
import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
@ -42,9 +42,8 @@ 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,
@ -62,7 +61,7 @@ import {
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;
@ -491,14 +490,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),
@ -599,14 +598,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),
@ -849,7 +848,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;
@ -887,7 +886,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
role,
signingOrder,
actionAuth: authOptions?.actionAuth,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata.requestMetadata,
}).catch(() => null);
if (!updatedRecipient) {
@ -909,7 +908,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({
@ -941,7 +940,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 +962,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];
@ -1100,7 +1099,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
fieldRecipientId: recipientId,
fieldType: field.type,
},
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata.requestMetadata,
}),
});
@ -1134,7 +1133,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;
@ -1198,7 +1197,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
pageY,
pageWidth,
pageHeight,
requestMetadata: extractNextApiRequestMetadata(args.req),
requestMetadata: metadata.requestMetadata,
fieldMeta: fieldMeta ? ZFieldMetaSchema.parse(fieldMeta) : undefined,
});
@ -1225,7 +1224,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({
@ -1286,7 +1285,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 { 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 { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { Team, User } from '@documenso/prisma/client';
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), // Todo: Test
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,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

@ -7,7 +7,7 @@ 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';
@ -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

@ -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

@ -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();
@ -607,19 +600,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

@ -10,6 +10,7 @@ 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

@ -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

@ -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

@ -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 { 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

@ -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,136 @@
import type { ClientResponse, InferRequestType } from 'hono/client';
import { hc } from 'hono/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import type { AuthAppType } from '../server';
import { handleSignInRedirect } from '../server/lib/utils/redirect';
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 session() {
return this.client.session.$get();
}
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 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['google'].authorize.$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;
}
},
};
}
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,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 { emailPasswordRoute } from './routes/email-password';
import { googleRoute } from './routes/google';
import { passkeyRoute } from './routes/passkey';
import { sessionRoute } from './routes/session';
import { signOutRoute } from './routes/sign-out';
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));
// Todo: Maybe use auth URL.
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('/email-password', emailPasswordRoute)
.route('/passkey', passkeyRoute)
.route('/google', googleRoute);
/**
* Handle errors.
*/
auth.onError((err, c) => {
// Todo Remove
console.error(`${err}`);
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,113 @@
import type { Context } from 'hono';
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { useSecureCookies } from '@documenso/lib/constants/auth';
import { appLog } from '@documenso/lib/utils/debugger';
import { env } from '@documenso/lib/utils/env';
import { generateSessionToken } from './session';
export const sessionCookieName = 'sessionId';
const getAuthSecret = () => {
const authSecret = env('NEXTAUTH_SECRET');
if (!authSecret) {
throw new Error('NEXTAUTH_SECRET is not set');
}
return authSecret;
};
const getAuthDomain = () => {
const url = new URL(NEXT_PUBLIC_WEBAPP_URL());
return url.hostname;
};
/**
* Generic auth session cookie options.
*/
export const sessionCookieOptions = {
httpOnly: true,
path: '/',
sameSite: useSecureCookies ? 'none' : 'lax', // Todo: This feels wrong?
secure: useSecureCookies,
domain: getAuthDomain(),
// Todo: Max age for specific auth cookies.
} as const;
export const extractSessionCookieFromHeaders = (headers: Headers): string | null => {
const cookieHeader = headers.get('cookie') || '';
const cookiePairs = cookieHeader.split(';');
const sessionCookie = cookiePairs.find((pair) => pair.trim().startsWith(sessionCookieName));
if (!sessionCookie) {
return null;
}
return sessionCookie.split('=')[1].trim();
};
/**
* 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(), 'csrfToken');
return csrfToken || null;
};
export const setCsrfCookie = async (c: Context) => {
const csrfToken = generateSessionToken();
await setSignedCookie(c, 'csrfToken', csrfToken, getAuthSecret(), {
...sessionCookieOptions,
// Explicity set to undefined for session lived cookie.
expires: undefined,
maxAge: undefined,
});
return csrfToken;
};

View File

@ -0,0 +1,146 @@
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';
/**
* 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'
>;
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() + 1000 * 60 * 60 * 24 * 30),
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,
},
},
},
});
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,30 @@
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);
// Todo.
// Create the Stripe customer and attach it to the user if it doesn't exist.
// if (user.customerId === null && IS_BILLING_ENABLED()) {
// await getStripeCustomerByUser(user).catch((err) => {
// console.error(err);
// });
// }
};

View File

@ -0,0 +1,54 @@
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): Promise<SessionValidationResult> => {
// Todo: Make better
const sessionId = await getSessionCookie(mapRequestToContextForCookie(c));
if (!sessionId) {
return {
isAuthenticated: false,
session: null,
user: null,
};
}
return await validateSessionToken(sessionId);
};
export const getRequiredSession = async (c: Context | Request) => {
const { session, user } = await getSession(mapRequestToContextForCookie(c));
if (session && user) {
return { session, user };
}
// Todo: Test if throwing errors work
if (c instanceof Request) {
throw new Error('Unauthorized');
}
throw new AppError(AuthenticationErrorCode.Unauthorized);
};
const mapRequestToContextForCookie = (c: Context | Request) => {
if (c instanceof Request) {
// c.req.raw.headers.
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,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,401 @@
import { sValidator } from '@hono/standard-validator';
import { compare } from '@node-rs/bcrypt';
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 { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { getCsrfCookie } from '../lib/session/session-cookies';
import { onAuthorize } from '../lib/utils/authorizer';
import { getRequiredSession, 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: 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);
if (!session.isAuthenticated) {
throw new AppError(AuthenticationErrorCode.Unauthorized);
}
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 getRequiredSession(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 getRequiredSession(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 getRequiredSession(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 getRequiredSession(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,238 @@
import { sValidator } from '@hono/standard-validator';
import { Google, decodeIdToken, generateCodeVerifier, generateState } from 'arctic';
import { Hono } from 'hono';
import { deleteCookie, setCookie } from 'hono/cookie';
import { z } from 'zod';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { sessionCookieOptions } from '../lib/session/session-cookies';
import { onAuthorize } from '../lib/utils/authorizer';
import type { HonoAuthContext } from '../types/context';
const options = {
clientId: env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET') ?? '',
redirectUri: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/google/callback`,
scope: ['openid', 'email', 'profile'],
id: 'google',
};
const google = new Google(options.clientId, options.clientSecret, options.redirectUri);
// todo: NEXT_PRIVATE_OIDC_WELL_KNOWN???
const ZGoogleAuthorizeSchema = z.object({
redirectPath: z.string().optional(),
});
export const googleRoute = new Hono<HonoAuthContext>()
/**
* Authorize endpoint.
*/
.post('/authorize', sValidator('json', ZGoogleAuthorizeSchema), (c) => {
const scopes = options.scope;
const state = generateState();
const codeVerifier = generateCodeVerifier();
const url = google.createAuthorizationURL(state, codeVerifier, scopes);
const { redirectPath } = c.req.valid('json');
setCookie(c, 'google_oauth_state', state, {
...sessionCookieOptions,
sameSite: 'lax', // Todo
maxAge: 60 * 10, // 10 minutes.
});
setCookie(c, 'google_code_verifier', codeVerifier, {
...sessionCookieOptions,
sameSite: 'lax', // Todo
maxAge: 60 * 10, // 10 minutes.
});
if (redirectPath) {
setCookie(c, 'google_redirect_path', `${state}:${redirectPath}`, {
...sessionCookieOptions,
sameSite: 'lax', // Todo
maxAge: 60 * 10, // 10 minutes.
});
}
return c.json({
redirectUrl: url.toString(),
});
})
/**
* Google callback verification.
*/
.get('/callback', async (c) => {
const requestMeta = c.get('requestMetadata');
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = deleteCookie(c, 'google_oauth_state');
const storedCodeVerifier = deleteCookie(c, 'google_code_verifier');
const storedredirectPath = deleteCookie(c, 'google_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 google.validateAuthorizationCode(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 googleEmail = claims.email;
const googleEmailVerified = claims.email_verified;
const googleName = claims.name;
const googleSub = claims.sub;
if (
typeof googleEmail !== 'string' ||
typeof googleName !== 'string' ||
typeof googleSub !== 'string'
) {
throw new AppError(AuthenticationErrorCode.InvalidRequest, {
message: 'Invalid google claims',
});
}
if (claims.email_verified !== true) {
throw new AppError(AuthenticationErrorCode.UnverifiedEmail, {
message: 'Account email is not verified',
});
}
// Find the account if possible.
const existingAccount = await prisma.account.findFirst({
where: {
provider: 'google',
providerAccountId: googleSub,
},
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: googleEmail,
},
});
// Handle existing user but no account.
if (userWithSameEmail) {
await prisma.$transaction(async (tx) => {
await tx.account.create({
data: {
type: 'oauth',
provider: 'google',
providerAccountId: googleSub,
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: Check this
},
});
}
// Apparently incredibly rare case? So we whole account to unverified.
if (!googleEmailVerified) {
// Todo: Add logging.
await tx.user.update({
where: {
id: userWithSameEmail.id,
},
data: {
emailVerified: null,
},
});
}
});
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: googleEmail,
name: googleName,
},
});
await tx.account.create({
data: {
type: 'oauth',
provider: 'google',
providerAccountId: googleSub,
access_token: accessToken,
expires_at: Math.floor(accessTokenExpiresAt.getTime() / 1000),
token_type: 'Bearer',
id_token: idToken,
userId: user.id,
},
});
return user;
});
await onAuthorize({ userId: createdUser.id }, c);
return c.redirect(redirectPath, 302);
});

View File

@ -0,0 +1,146 @@
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,
);
});
// Todo
// .post('/register', async (c) => {
// const { user } = await getRequiredSession(c);
// //
// })
// .post(
// '/pre-authenticate',
// sValidator(
// 'json',
// z.object({
// code: z.string(),
// }),
// ),
// async (c) => {
// //
// return c.json({
// success: true,
// recoveryCodes: result.recoveryCodes,
// });
// },
// );

View File

@ -0,0 +1,10 @@
import { Hono } from 'hono';
import type { SessionValidationResult } from '../lib/session/session';
import { getSession } from '../lib/utils/get-session';
export const sessionRoute = new Hono().get('/session', async (c) => {
const session: SessionValidationResult = await getSession(c);
return c.json(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,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,18 @@
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>,
) => {
try {
const token = await getToken({ req });
// res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
const rawTeamId = req.headers['team-id'];
export const limitsHandler = async (req: Request) => {
try {
// Todo: Check
const { user } = await getSession(req);
const rawTeamId = req.headers.get('team-id');
let teamId: number | null = null;
@ -26,9 +24,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 +37,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,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,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

@ -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,37 +16,50 @@ type StripeWebhookResponse = {
message: string;
};
export const stripeWebhookHandler = async (
req: NextApiRequest,
res: NextApiResponse<StripeWebhookResponse>,
) => {
export const stripeWebhookHandler = async (req: Request) => {
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);
// Todo: I'm not sure about this.
const clonedReq = req.clone();
const rawBody = await clonedReq.arrayBuffer();
const body = Buffer.from(rawBody);
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
);
// It was this:
// const body = await buffer(req);
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
await match(event.type)
.with('checkout.session.completed', async () => {
@ -92,10 +103,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 +115,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 +156,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 +183,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 +225,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 +254,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 +284,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 +316,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 +345,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 +375,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 +400,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 +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

@ -35,9 +35,9 @@
"@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": "*",

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';

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,7 +1,7 @@
import { useMemo } from 'react';
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';

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

@ -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';

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,13 +1,14 @@
import { Trans, msg } from '@lingui/macro';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { RecipientRole } from '@documenso/prisma/client';
import { Body, Button, Container, Head, Html, Img, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
import TemplateDocumentImage from '../template-components/template-document-image';
import { TemplateFooter } from '../template-components/template-footer';
import { RecipientRole } from '.prisma/client';
export type DocumentCompletedEmailTemplateProps = {
recipientName?: string;

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 { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { RecipientRole } from '@documenso/prisma/client';
@ -30,7 +31,7 @@ export const DocumentInviteEmailTemplate = ({
role,
selfSigner = false,
isTeamInvite = false,
teamName,
teamName = '',
includeSenderDetails,
}: DocumentInviteEmailTemplateProps) => {
const { _ } = useLingui();

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,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,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,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,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,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';

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 { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';

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 { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';

View File

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

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,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,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,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,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,7 +1,9 @@
import { SentMessageInfo, Transport } from 'nodemailer';
import type { SentMessageInfo, Transport } from 'nodemailer';
import type { Address } from 'nodemailer/lib/mailer';
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
import { env } from '@documenso/lib/utils/env';
const VERSION = '1.0.0';
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
@ -79,9 +81,9 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
to: mailTo,
cc: mailCc.length > 0 ? mailCc : undefined,
bcc: mailBcc.length > 0 ? mailBcc : undefined,
dkim_domain: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN || undefined,
dkim_selector: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR || undefined,
dkim_private_key: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY || undefined,
dkim_domain: env('NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN') || undefined,
dkim_selector: env('NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR') || undefined,
dkim_private_key: env('NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY') || undefined,
},
],
content: [

View File

@ -1,4 +1,4 @@
import type { DocumentData } from '@documenso/prisma/client';
import type { DocumentData } from '@prisma/client';
import { getFile } from '../universal/upload/get-file';
import { downloadFile } from './download-file';

View File

@ -1,13 +1,9 @@
import { posthog } from 'posthog-js';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import {
FEATURE_FLAG_GLOBAL_SESSION_RECORDING,
extractPostHogConfig,
} from '@documenso/lib/constants/feature-flags';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export function useAnalytics() {
const featureFlags = useFeatureFlags();
// const featureFlags = useFeatureFlags();
const isPostHogEnabled = extractPostHogConfig();
/**
@ -30,27 +26,29 @@ export function useAnalytics() {
* @param eventFlag The event to check against feature flags to determine whether tracking is enabled.
*/
const startSessionRecording = (eventFlag?: string) => {
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag));
return;
// const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
// const isSessionRecordingEnabledForEvent = Boolean(eventFlag && featureFlags.getFlag(eventFlag));
if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) {
return;
}
// if (!isPostHogEnabled || !isSessionRecordingEnabled || !isSessionRecordingEnabledForEvent) {
// return;
// }
posthog.startSessionRecording();
// posthog.startSessionRecording();
};
/**
* Stop the current session recording.
*/
const stopSessionRecording = () => {
const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
return;
// const isSessionRecordingEnabled = featureFlags.getFlag(FEATURE_FLAG_GLOBAL_SESSION_RECORDING);
if (!isPostHogEnabled || !isSessionRecordingEnabled) {
return;
}
// if (!isPostHogEnabled || !isSessionRecordingEnabled) {
// return;
// }
posthog.stopSessionRecording();
// posthog.stopSessionRecording();
};
return {

View File

@ -1,5 +1,3 @@
'use client';
import { useCallback } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';

View File

@ -1,25 +0,0 @@
import { useMemo } from 'react';
import type { CombinedStylesKey } from '../../../ui/primitives/document-flow/add-fields';
import { combinedStyles } from '../../../ui/primitives/document-flow/field-item';
const defaultFieldItemStyles = {
borderClass: 'border-field-card-border',
activeBorderClass: 'border-field-card-border/80',
initialsBGClass: 'text-field-card-foreground/50 bg-slate-900/10',
fieldBackground: 'bg-field-card-background',
};
export const useFieldItemStyles = (color: CombinedStylesKey | null) => {
return useMemo(() => {
if (!color) return defaultFieldItemStyles;
const selectedColorVariant = combinedStyles[color];
return {
activeBorderClass: selectedColorVariant?.borderActive,
borderClass: selectedColorVariant?.border,
initialsBGClass: selectedColorVariant?.initialsBG,
fieldBackground: selectedColorVariant?.fieldBackground,
};
}, [color]);
};

View File

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

View File

@ -1,9 +1,9 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
export const useUpdateSearchParams = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const navigate = useNavigate();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
return (params: Record<string, string | number | boolean | null | undefined>) => {
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
@ -16,6 +16,6 @@ export const useUpdateSearchParams = () => {
}
});
router.push(`${pathname}?${nextSearchParams.toString()}`);
void navigate(`${pathname}?${nextSearchParams.toString()}`);
};
};

View File

@ -1,95 +0,0 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import {
FEATURE_FLAG_POLL_INTERVAL,
LOCAL_FEATURE_FLAGS,
isFeatureFlagEnabled,
} from '@documenso/lib/constants/feature-flags';
import { getAllFlags } from '@documenso/lib/universal/get-feature-flag';
import { TFeatureFlagValue } from './feature-flag.types';
export type FeatureFlagContextValue = {
getFlag: (_key: string) => TFeatureFlagValue;
};
export const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
export const useFeatureFlags = () => {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
}
return context;
};
export function FeatureFlagProvider({
children,
initialFlags,
}: {
children: React.ReactNode;
initialFlags: Record<string, TFeatureFlagValue>;
}) {
const [flags, setFlags] = useState(initialFlags);
const getFlag = useCallback(
(flag: string) => {
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
return flags[flag] ?? false;
},
[flags],
);
/**
* Refresh the flags every `FEATURE_FLAG_POLL_INTERVAL` amount of time if the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const interval = setInterval(() => {
if (document.hasFocus()) {
void getAllFlags().then((newFlags) => setFlags(newFlags));
}
}, FEATURE_FLAG_POLL_INTERVAL);
return () => {
clearInterval(interval);
};
}, []);
/**
* Refresh the flags when the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
return (
<FeatureFlagContext.Provider
value={{
getFlag,
}}
>
{children}
</FeatureFlagContext.Provider>
);
}

View File

@ -1,10 +0,0 @@
import { z } from 'zod';
export const ZFeatureFlagValueSchema = z.union([
z.boolean(),
z.string(),
z.number(),
z.undefined(),
]);
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;

View File

@ -1,17 +1,12 @@
import 'server-only';
import { cookies, headers } from 'next/headers';
import type { I18n, Messages } from '@lingui/core';
import { setupI18n } from '@lingui/core';
import { setI18n } from '@lingui/react/server';
import {
APP_I18N_OPTIONS,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '../../constants/i18n';
import { extractLocaleData } from '../../utils/i18n';
import { env } from '../../utils/env';
import { remember } from '../../utils/remember';
type SupportedLanguages = (typeof SUPPORTED_LANGUAGE_CODES)[number];
@ -19,7 +14,7 @@ type SupportedLanguages = (typeof SUPPORTED_LANGUAGE_CODES)[number];
export async function loadCatalog(lang: SupportedLanguages): Promise<{
[k: string]: Messages;
}> {
const extension = process.env.NODE_ENV === 'development' ? 'po' : 'js';
const extension = env('NODE_ENV') === 'development' ? 'po' : 'mjs';
const { messages } = await import(`../../translations/${lang}/web.${extension}`);
@ -71,29 +66,3 @@ export const getI18nInstance = async (lang?: SupportedLanguages | (string & {}))
return instances[lang] ?? instances[APP_I18N_OPTIONS.sourceLang];
};
/**
* This needs to be run in all layouts and page server components that require i18n.
*
* https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui
*/
export const setupI18nSSR = async () => {
const { lang, locales } = extractLocaleData({
cookies: cookies(),
headers: headers(),
});
// Get and set a ready-made i18n instance for the given language.
const i18n = await getI18nInstance(lang);
// Reactivate the i18n instance with the locale for date and number formatting.
i18n.activate(lang, locales);
setI18n(i18n);
return {
lang,
locales,
i18n,
};
};

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