fix: merge conflicts

This commit is contained in:
Ephraim Atta-Duncan
2025-06-19 15:16:16 +00:00
750 changed files with 46465 additions and 29408 deletions

View File

@ -13,12 +13,10 @@ import {
ZDeleteRecipientMutationSchema,
ZDownloadDocumentQuerySchema,
ZDownloadDocumentSuccessfulSchema,
ZFindTeamMembersResponseSchema,
ZGenerateDocumentFromTemplateMutationResponseSchema,
ZGenerateDocumentFromTemplateMutationSchema,
ZGetDocumentsQuerySchema,
ZGetTemplatesQuerySchema,
ZInviteTeamMemberMutationSchema,
ZNoBodyMutationSchema,
ZResendDocumentForSigningMutationSchema,
ZSendDocumentForSigningMutationSchema,
@ -29,17 +27,13 @@ import {
ZSuccessfulGetDocumentResponseSchema,
ZSuccessfulGetTemplateResponseSchema,
ZSuccessfulGetTemplatesResponseSchema,
ZSuccessfulInviteTeamMemberResponseSchema,
ZSuccessfulRecipientResponseSchema,
ZSuccessfulRemoveTeamMemberResponseSchema,
ZSuccessfulResendDocumentResponseSchema,
ZSuccessfulResponseSchema,
ZSuccessfulSigningResponseSchema,
ZSuccessfulUpdateTeamMemberResponseSchema,
ZUnsuccessfulResponseSchema,
ZUpdateFieldMutationSchema,
ZUpdateRecipientMutationSchema,
ZUpdateTeamMemberMutationSchema,
} from './schema';
const c = initContract();
@ -284,61 +278,6 @@ export const ApiContractV1 = c.router(
},
summary: 'Delete a field from a document',
},
findTeamMembers: {
method: 'GET',
path: '/api/v1/team/:id/members',
responses: {
200: ZFindTeamMembersResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'List team members',
},
inviteTeamMember: {
method: 'POST',
path: '/api/v1/team/:id/members/invite',
body: ZInviteTeamMemberMutationSchema,
responses: {
200: ZSuccessfulInviteTeamMemberResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Invite a member to a team',
},
updateTeamMember: {
method: 'PUT',
path: '/api/v1/team/:id/members/:memberId',
body: ZUpdateTeamMemberMutationSchema,
responses: {
200: ZSuccessfulUpdateTeamMemberResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Update a team member',
},
removeTeamMember: {
method: 'DELETE',
path: '/api/v1/team/:id/members/:memberId',
body: null,
responses: {
200: ZSuccessfulRemoveTeamMemberResponseSchema,
400: ZUnsuccessfulResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
500: ZUnsuccessfulResponseSchema,
},
summary: 'Remove a member from a team',
},
},
{
baseHeaders: ZAuthorizationHeadersSchema,

View File

@ -1,5 +1,5 @@
import type { Prisma } from '@prisma/client';
import { DocumentDataType, SigningStatus, TeamMemberRole } from '@prisma/client';
import { DocumentDataType, SigningStatus } from '@prisma/client';
import { tsr } from '@ts-rest/serverless/fetch';
import { match } from 'ts-pattern';
@ -27,9 +27,7 @@ import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-rec
import { getRecipientByIdV1Api } from '@documenso/lib/server-only/recipient/get-recipient-by-id-v1-api';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites';
import { deleteTeamMembers } from '@documenso/lib/server-only/team/delete-team-members';
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
@ -52,6 +50,7 @@ import {
} from '@documenso/lib/universal/upload/server-actions';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { ApiContractV1 } from './contract';
@ -258,7 +257,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
const { remaining } = await getServerLimits({ userId: user.id, teamId: team.id });
if (remaining.documents <= 0) {
return {
@ -467,7 +466,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
const { remaining } = await getServerLimits({ userId: user.id, teamId: team?.id });
if (remaining.documents <= 0) {
return {
@ -565,7 +564,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
const { body, params } = args;
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
const { remaining } = await getServerLimits({ userId: user.id, teamId: team?.id });
if (remaining.documents <= 0) {
return {
@ -821,7 +820,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
name,
role,
signingOrder,
actionAuth: authOptions?.actionAuth ?? null,
actionAuth: authOptions?.actionAuth ?? [],
},
],
requestMetadata: metadata,
@ -879,18 +878,24 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const updatedRecipient = await updateRecipient({
documentId: Number(documentId),
recipientId: Number(recipientId),
const updatedRecipient = await updateDocumentRecipients({
userId: user.id,
teamId: team?.id,
email,
name,
role,
signingOrder,
actionAuth: authOptions?.actionAuth,
requestMetadata: metadata.requestMetadata,
}).catch(() => null);
teamId: team.id,
documentId: Number(documentId),
recipients: [
{
id: Number(recipientId),
email,
name,
role,
signingOrder,
actionAuth: authOptions?.actionAuth ?? [],
},
],
requestMetadata: metadata,
})
.then(({ recipients }) => recipients[0])
.catch(null);
if (!updatedRecipient) {
return {
@ -973,17 +978,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
select: { id: true, status: true },
where: {
id: Number(documentId),
...(team?.id
? {
team: {
id: team.id,
members: { some: { userId: user.id } },
},
}
: {
userId: user.id,
teamId: null,
}),
team: buildTeamWhereQuery({ teamId: team.id, userId: user.id }),
},
});
@ -1233,6 +1228,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
const document = await getDocumentById({
documentId: Number(documentId),
userId: user.id,
teamId: team.id,
});
if (!document) {
@ -1257,7 +1253,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id,
teamId: team?.id,
fieldId: Number(fieldId),
documentId: Number(documentId),
}).catch(() => null);
if (!field) {
@ -1322,272 +1317,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
},
};
}),
findTeamMembers: authenticatedMiddleware(async (args, user, team) => {
const { id: teamId } = args.params;
if (team?.id !== Number(teamId)) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const self = await prisma.teamMember.findFirst({
where: {
userId: user.id,
teamId: team.id,
},
});
if (self?.role !== TeamMemberRole.ADMIN) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const members = await prisma.teamMember.findMany({
where: {
teamId: team.id,
},
include: {
user: true,
},
});
return {
status: 200,
body: {
members: members.map((member) => ({
id: member.id,
email: member.user.email,
role: member.role,
})),
},
};
}),
inviteTeamMember: authenticatedMiddleware(async (args, user, team) => {
const { id: teamId } = args.params;
const { email, role } = args.body;
if (team?.id !== Number(teamId)) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const self = await prisma.teamMember.findFirst({
where: {
userId: user.id,
teamId: team.id,
},
});
if (self?.role !== TeamMemberRole.ADMIN) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const hasAlreadyBeenInvited = await prisma.teamMember.findFirst({
where: {
teamId: team.id,
user: {
email,
},
},
});
if (hasAlreadyBeenInvited) {
return {
status: 400,
body: {
message: 'This user has already been invited to the team',
},
};
}
await createTeamMemberInvites({
userId: user.id,
userName: user.name ?? '',
teamId: team.id,
invitations: [
{
email,
role,
},
],
});
return {
status: 200,
body: {
message: 'An invite has been sent to the member',
},
};
}),
updateTeamMember: authenticatedMiddleware(async (args, user, team) => {
const { id: teamId, memberId } = args.params;
const { role } = args.body;
if (team?.id !== Number(teamId)) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const self = await prisma.teamMember.findFirst({
where: {
userId: user.id,
teamId: team.id,
},
});
if (self?.role !== TeamMemberRole.ADMIN) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const member = await prisma.teamMember.findFirst({
where: {
id: Number(memberId),
teamId: team.id,
},
});
if (!member) {
return {
status: 404,
body: {
message: 'The provided member id does not exist.',
},
};
}
const updatedMember = await prisma.teamMember.update({
where: {
id: member.id,
},
data: {
role,
},
include: {
user: true,
},
});
return {
status: 200,
body: {
id: updatedMember.id,
email: updatedMember.user.email,
role: updatedMember.role,
},
};
}),
removeTeamMember: authenticatedMiddleware(async (args, user, team) => {
const { id: teamId, memberId } = args.params;
if (team?.id !== Number(teamId)) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const self = await prisma.teamMember.findFirst({
where: {
userId: user.id,
teamId: team.id,
},
});
if (self?.role !== TeamMemberRole.ADMIN) {
return {
status: 403,
body: {
message: 'You are not authorized to perform actions against this team.',
},
};
}
const member = await prisma.teamMember.findFirst({
where: {
id: Number(memberId),
teamId: Number(teamId),
},
include: {
user: true,
},
});
if (!member) {
return {
status: 404,
body: {
message: 'Member not found',
},
};
}
if (team.ownerUserId === member.userId) {
return {
status: 403,
body: {
message: 'You cannot remove the owner of the team',
},
};
}
if (member.userId === user.id) {
return {
status: 403,
body: {
message: 'You cannot remove yourself from the team',
},
};
}
await deleteTeamMembers({
userId: user.id,
teamId: team.id,
teamMemberIds: [member.id],
});
return {
status: 200,
body: {
id: member.id,
email: member.user.email,
role: member.role,
},
};
}),
});
const updateDocument = async ({
@ -1599,26 +1328,13 @@ const updateDocument = async ({
documentId: number;
data: Prisma.DocumentUpdateInput;
userId: number;
teamId?: number;
teamId: number;
}) => {
return await prisma.document.update({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
userId,
team: buildTeamWhereQuery({ teamId, userId }),
},
data: {
...data,

View File

@ -25,8 +25,8 @@ export const authenticatedMiddleware = <
>(
handler: (
args: T & { req: TsRestRequest },
user: User,
team: Team | null | undefined,
user: Pick<User, 'id' | 'email' | 'name' | 'disabled'>,
team: Team,
options: { metadata: ApiRequestMetadata },
) => Promise<R>,
) => {

View File

@ -8,7 +8,6 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@prisma/client';
import { z } from 'zod';
@ -177,8 +176,16 @@ export const ZCreateDocumentMutationSchema = z.object({
.default({}),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z
.union([ZDocumentAccessAuthTypesSchema, z.array(ZDocumentAccessAuthTypesSchema)])
.transform((val) => (Array.isArray(val) ? val : [val]))
.optional()
.default([]),
globalActionAuth: z
.union([ZDocumentActionAuthTypesSchema, z.array(ZDocumentActionAuthTypesSchema)])
.transform((val) => (Array.isArray(val) ? val : [val]))
.optional()
.default([]),
})
.optional()
.openapi({
@ -237,8 +244,16 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
.optional(),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z
.union([ZDocumentAccessAuthTypesSchema, z.array(ZDocumentAccessAuthTypesSchema)])
.transform((val) => (Array.isArray(val) ? val : [val]))
.optional()
.default([]),
globalActionAuth: z
.union([ZDocumentActionAuthTypesSchema, z.array(ZDocumentActionAuthTypesSchema)])
.transform((val) => (Array.isArray(val) ? val : [val]))
.optional()
.default([]),
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
@ -310,8 +325,16 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
.optional(),
authOptions: z
.object({
globalAccessAuth: ZDocumentAccessAuthTypesSchema.optional(),
globalActionAuth: ZDocumentActionAuthTypesSchema.optional(),
globalAccessAuth: z
.union([ZDocumentAccessAuthTypesSchema, z.array(ZDocumentAccessAuthTypesSchema)])
.transform((val) => (Array.isArray(val) ? val : [val]))
.optional()
.default([]),
globalActionAuth: z
.union([ZDocumentActionAuthTypesSchema, z.array(ZDocumentActionAuthTypesSchema)])
.transform((val) => (Array.isArray(val) ? val : [val]))
.optional()
.default([]),
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
@ -350,7 +373,11 @@ export const ZCreateRecipientMutationSchema = z.object({
signingOrder: z.number().nullish(),
authOptions: z
.object({
actionAuth: ZRecipientActionAuthTypesSchema.optional(),
actionAuth: z
.union([ZRecipientActionAuthTypesSchema, z.array(ZRecipientActionAuthTypesSchema)])
.transform((val) => (Array.isArray(val) ? val : [val]))
.optional()
.default([]),
})
.optional()
.openapi({
@ -600,41 +627,3 @@ export const ZGetTemplatesQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
});
export const ZFindTeamMembersResponseSchema = z.object({
members: z.array(
z.object({
id: z.number(),
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
}),
),
});
export const ZInviteTeamMemberMutationSchema = z.object({
email: z
.string()
.email()
.transform((email) => email.toLowerCase()),
role: z.nativeEnum(TeamMemberRole).optional().default(TeamMemberRole.MEMBER),
});
export const ZSuccessfulInviteTeamMemberResponseSchema = z.object({
message: z.string(),
});
export const ZUpdateTeamMemberMutationSchema = z.object({
role: z.nativeEnum(TeamMemberRole),
});
export const ZSuccessfulUpdateTeamMemberResponseSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
});
export const ZSuccessfulRemoveTeamMemberResponseSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
});

View File

@ -8,15 +8,17 @@ import { seedUser } from '@documenso/prisma/seed/users';
test.describe('Document API', () => {
test('sendDocument: should respect sendCompletionEmails setting', async ({ request }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['signer@example.com'],
teamId: team.id,
});
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
@ -81,11 +83,12 @@ test.describe('Document API', () => {
test('sendDocument: should not modify email settings when sendCompletionEmails is not provided', async ({
request,
}) => {
const user = await seedUser();
const { user, team } = await seedUser();
const { document } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['signer@example.com'],
teamId: team.id,
});
// Set initial email settings
@ -109,6 +112,7 @@ test.describe('Document API', () => {
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});

View File

@ -1,281 +0,0 @@
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import {
ZFindTeamMembersResponseSchema,
ZSuccessfulInviteTeamMemberResponseSchema,
ZSuccessfulRemoveTeamMemberResponseSchema,
ZSuccessfulUpdateTeamMemberResponseSchema,
ZUnsuccessfulResponseSchema,
} from '@documenso/api/v1/schema';
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 { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
test.describe('Team API', () => {
test('findTeamMembers: should list team members', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const ownerMember = team.members.find((member) => member.userId === team.owner.id)!;
// Should not be undefined
expect(ownerMember).toBeTruthy();
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
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);
const data = await response.json();
const parsed = ZFindTeamMembersResponseSchema.safeParse(data);
const safeData = parsed.success ? parsed.data : null;
expect(parsed.success).toBeTruthy();
expect(safeData!.members).toHaveLength(4); // Owner + 3 members
expect(safeData!.members[0]).toHaveProperty('id');
expect(safeData!.members[0]).toHaveProperty('email');
expect(safeData!.members[0]).toHaveProperty('role');
expect(safeData!.members).toContainEqual({
id: ownerMember.id,
email: ownerMember.user.email,
role: ownerMember.role,
});
});
test('inviteTeamMember: should invite a new team member', async ({ request }) => {
const team = await seedTeam();
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const newUser = await seedUser();
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/invite`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
email: newUser.email,
role: TeamMemberRole.MEMBER,
},
},
);
expect(response.ok()).toBeTruthy();
const data = await response.json();
const parsed = ZSuccessfulInviteTeamMemberResponseSchema.safeParse(data);
const safeData = parsed.success ? parsed.data : null;
expect(parsed.success).toBeTruthy();
expect(safeData!.message).toBe('An invite has been sent to the member');
const invite = await prisma.teamMemberInvite.findFirst({
where: {
email: newUser.email,
teamId: team.id,
},
});
expect(invite).toBeTruthy();
});
test('updateTeamMember: should update a team member role', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const member = team.members.find((member) => member.role === TeamMemberRole.MEMBER)!;
// Should not be undefined
expect(member).toBeTruthy();
const response = await request.put(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
role: TeamMemberRole.ADMIN,
},
},
);
expect(response.ok()).toBeTruthy();
const data = await response.json();
const parsed = ZSuccessfulUpdateTeamMemberResponseSchema.safeParse(data);
const safeData = parsed.success ? parsed.data : null;
expect(parsed.success).toBeTruthy();
expect(safeData!.id).toBe(member.id);
expect(safeData!.email).toBe(member.user.email);
expect(safeData!.role).toBe(TeamMemberRole.ADMIN);
});
test('removeTeamMember: should remove a team member', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const member = team.members.find((member) => member.role === TeamMemberRole.MEMBER)!;
// Should not be undefined
expect(member).toBeTruthy();
const response = await request.delete(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
expect(response.status()).toBe(200);
const data = await response.json();
const parsed = ZSuccessfulRemoveTeamMemberResponseSchema.safeParse(data);
const safeData = parsed.success ? parsed.data : null;
expect(parsed.success).toBeTruthy();
expect(safeData!.id).toBe(member.id);
expect(safeData!.email).toBe(member.user.email);
expect(safeData!.role).toBe(member.role);
const removedMemberCount = await prisma.teamMember.count({
where: {
id: member.id,
teamId: team.id,
},
});
expect(removedMemberCount).toBe(0);
});
test('removeTeamMember: should not remove team owner', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const { token } = await createApiToken({
userId: team.owner.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const ownerMember = team.members.find((member) => member.userId === team.owner.id)!;
// Should not be undefined
expect(ownerMember).toBeTruthy();
const response = await request.delete(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${ownerMember.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
expect(response.status()).toBe(403);
const parsed = ZUnsuccessfulResponseSchema.safeParse(await response.json());
expect(parsed.success).toBeTruthy();
});
test('removeTeamMember: should not remove self', async ({ request }) => {
const team = await seedTeam({
createTeamMembers: 3,
});
const member = team.members.find((member) => member.role === TeamMemberRole.MEMBER)!;
// Make our non-owner member an admin
await prisma.teamMember.update({
where: {
id: member.id,
},
data: {
role: TeamMemberRole.ADMIN,
},
});
const { token } = await createApiToken({
userId: member.userId,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await request.delete(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v1/team/${team.id}/members/${member.id}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
expect(response.status()).toBe(403);
const parsed = ZUnsuccessfulResponseSchema.safeParse(await response.json());
expect(parsed.success).toBeTruthy();
});
});

View File

@ -18,17 +18,18 @@ test.describe('Template Field Prefill API v1', () => {
request,
}) => {
// 1. Create a user
const user = await seedUser();
const { user, team } = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: 'Template with Advanced Fields',
},
@ -196,7 +197,7 @@ test.describe('Template Field Prefill API v1', () => {
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
value: '98765',
},
{
id: radioField.id,
@ -255,7 +256,7 @@ test.describe('Template Field Prefill API v1', () => {
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
value: '98765',
});
const documentRadioField = document?.fields.find(
@ -328,7 +329,7 @@ test.describe('Template Field Prefill API v1', () => {
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
await expect(page.getByText('98765', { exact: true })).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
@ -349,17 +350,18 @@ test.describe('Template Field Prefill API v1', () => {
request,
}) => {
// 1. Create a user
const user = await seedUser();
const { user, team } = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: 'Template with Default Fields',
},
@ -381,7 +383,7 @@ test.describe('Template Field Prefill API v1', () => {
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
@ -401,7 +403,7 @@ test.describe('Template Field Prefill API v1', () => {
});
// Add NUMBER field
const numberField = await prisma.field.create({
await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
@ -519,17 +521,18 @@ test.describe('Template Field Prefill API v1', () => {
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
const { user, team } = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: 'Template for Invalid Test',
visibility: 'EVERYONE',

View File

@ -8,10 +8,11 @@ test.describe('Embedding Presign API', () => {
test('createEmbeddingPresignToken: should create a token with default expiration', async ({
request,
}) => {
const user = await seedUser();
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
@ -44,10 +45,11 @@ test.describe('Embedding Presign API', () => {
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({
request,
}) => {
const user = await seedUser();
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
@ -81,10 +83,11 @@ test.describe('Embedding Presign API', () => {
test.skip('createEmbeddingPresignToken: should create a token with immediate expiration in dev mode', async ({
request,
}) => {
const user = await seedUser();
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
@ -116,10 +119,11 @@ test.describe('Embedding Presign API', () => {
});
test('verifyEmbeddingPresignToken: should verify a valid token', async ({ request }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
@ -170,10 +174,11 @@ test.describe('Embedding Presign API', () => {
});
test('verifyEmbeddingPresignToken: should reject an invalid token', async ({ request }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});

View File

@ -18,17 +18,18 @@ test.describe('Template Field Prefill API v2', () => {
request,
}) => {
// 1. Create a user
const user = await seedUser();
const { user, team } = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: 'Template with Advanced Fields V2',
},
@ -193,7 +194,7 @@ test.describe('Template Field Prefill API v2', () => {
id: numberField.id,
type: 'number',
label: 'Prefilled Number',
value: '42',
value: '98765',
},
{
id: radioField.id,
@ -252,7 +253,7 @@ test.describe('Template Field Prefill API v2', () => {
expect(documentNumberField?.fieldMeta).toMatchObject({
type: 'number',
label: 'Prefilled Number',
value: '42',
value: '98765',
});
const documentRadioField = document?.fields.find(
@ -325,7 +326,7 @@ test.describe('Template Field Prefill API v2', () => {
await expect(page.getByText('This is prefilled')).toBeVisible();
// Number field
await expect(page.getByText('42')).toBeVisible();
await expect(page.getByText('98765', { exact: true })).toBeVisible();
// Radio field
await expect(page.getByText('Option A')).toBeVisible();
@ -346,17 +347,18 @@ test.describe('Template Field Prefill API v2', () => {
request,
}) => {
// 1. Create a user
const user = await seedUser();
const { user, team } = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template with seedBlankTemplate
const template = await seedBlankTemplate(user, {
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: 'Template with Default Fields V2',
},
@ -378,7 +380,7 @@ test.describe('Template Field Prefill API v2', () => {
// 5. Add fields to the template
// Add TEXT field
const textField = await prisma.field.create({
await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
@ -398,7 +400,7 @@ test.describe('Template Field Prefill API v2', () => {
});
// Add NUMBER field
const numberField = await prisma.field.create({
await prisma.field.create({
data: {
templateId: template.id,
recipientId: recipient.id,
@ -511,17 +513,18 @@ test.describe('Template Field Prefill API v2', () => {
test('should handle invalid field prefill values', async ({ request }) => {
// 1. Create a user
const user = await seedUser();
const { user, team } = await seedUser();
// 2. Create an API token for the user
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test-token',
expiresIn: null,
});
// 3. Create a template using seedBlankTemplate
const template = await seedBlankTemplate(user, {
const template = await seedBlankTemplate(user, team.id, {
createTemplateOptions: {
title: 'Template for Invalid Test V2',
visibility: 'EVERYONE',

View File

@ -6,9 +6,9 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
const user = await seedUser();
const recipient = await seedUser();
const document = await seedPendingDocument(user, [recipient]);
const { user, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(user, team.id, [recipient]);
await apiSignin({
page,
@ -22,9 +22,9 @@ test('[COMMAND_MENU]: should see sent documents', async ({ page }) => {
});
test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
const user = await seedUser();
const recipient = await seedUser();
const document = await seedPendingDocument(user, [recipient]);
const { user, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(user, team.id, [recipient]);
await apiSignin({
page,
@ -38,9 +38,9 @@ test('[COMMAND_MENU]: should see received documents', async ({ page }) => {
});
test('[COMMAND_MENU]: should be able to search by recipient', async ({ page }) => {
const user = await seedUser();
const recipient = await seedUser();
const document = await seedPendingDocument(user, [recipient]);
const { user, team } = await seedUser();
const { user: recipient } = await seedUser();
const document = await seedPendingDocument(user, team.id, [recipient]);
await apiSignin({
page,

View File

@ -8,11 +8,11 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const recipientWithAccount = await seedUser();
const { user: recipientWithAccount } = await seedUser();
const document = await seedPendingDocument(user, [
const document = await seedPendingDocument(user, team.id, [
recipientWithAccount,
'recipientwithoutaccount@documenso.com',
]);
@ -32,18 +32,19 @@ test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page })
});
test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const recipientWithAccount = await seedUser();
const { user: recipientWithAccount } = await seedUser();
const document = await seedPendingDocument(
user,
team.id,
[recipientWithAccount, 'recipientwithoutaccount@documenso.com'],
{
createDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT',
globalActionAuth: null,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
},

View File

@ -18,12 +18,13 @@ import { signSignaturePad } from '../fixtures/signature';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const recipientWithAccount = await seedUser();
const { user: recipientWithAccount } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [recipientWithAccount, seedTestEmail()],
});
@ -56,17 +57,18 @@ test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }
});
test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const recipientWithAccount = await seedUser();
const { user: recipientWithAccount } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -107,17 +109,18 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
page,
}) => {
const user = await seedUser();
const { user, team } = await seedUser();
const recipientWithAccount = await seedUser();
const { user: recipientWithAccount } = await seedUser();
const { recipients } = await seedPendingDocumentNoFields({
owner: user,
teamId: team.id,
recipients: [recipientWithAccount],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -138,17 +141,18 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({
page,
}) => {
const user = await seedUser();
const { user, team } = await seedUser();
const recipientWithAccount = await seedUser();
const { user: recipientWithAccount } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [recipientWithAccount, seedTestEmail()],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -177,14 +181,15 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({
page,
}) => {
const user = await seedUser();
const { user, team } = await seedUser();
const recipientWithInheritAuth = await seedUser();
const recipientWithExplicitNoneAuth = await seedUser();
const recipientWithExplicitAccountAuth = await seedUser();
const { user: recipientWithInheritAuth } = await seedUser();
const { user: recipientWithExplicitNoneAuth } = await seedUser();
const { user: recipientWithExplicitAccountAuth } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [
recipientWithInheritAuth,
recipientWithExplicitNoneAuth,
@ -193,20 +198,20 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
accessAuth: [],
actionAuth: [],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
accessAuth: [],
actionAuth: ['EXPLICIT_NONE'],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
accessAuth: [],
actionAuth: ['ACCOUNT'],
}),
},
],
@ -218,7 +223,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document has no global action auth, so only account should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT';
const isAuthRequired = actionAuth.includes('ACCOUNT');
const signUrl = `/sign/${token}`;
@ -276,14 +281,15 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({
page,
}) => {
const user = await seedUser();
const { user, team } = await seedUser();
const recipientWithInheritAuth = await seedUser();
const recipientWithExplicitNoneAuth = await seedUser();
const recipientWithExplicitAccountAuth = await seedUser();
const { user: recipientWithInheritAuth } = await seedUser();
const { user: recipientWithExplicitNoneAuth } = await seedUser();
const { user: recipientWithExplicitAccountAuth } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [
recipientWithInheritAuth,
recipientWithExplicitNoneAuth,
@ -292,28 +298,28 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
recipientsCreateOptions: [
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: null,
accessAuth: [],
actionAuth: [],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'EXPLICIT_NONE',
accessAuth: [],
actionAuth: ['EXPLICIT_NONE'],
}),
},
{
authOptions: createRecipientAuthOptions({
accessAuth: null,
actionAuth: 'ACCOUNT',
accessAuth: [],
actionAuth: ['ACCOUNT'],
}),
},
],
fields: [FieldType.DATE, FieldType.SIGNATURE],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: null,
globalActionAuth: 'ACCOUNT',
globalAccessAuth: [],
globalActionAuth: ['ACCOUNT'],
}),
},
});
@ -323,7 +329,7 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
// This document HAS global action auth, so account and inherit should require auth.
const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null;
const isAuthRequired = actionAuth.includes('ACCOUNT') || actionAuth.length === 0;
const signUrl = `/sign/${token}`;

View File

@ -16,13 +16,14 @@ import { signDirectSignaturePad, signSignaturePad } from '../fixtures/signature'
test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dictation is enabled', async ({
page,
}) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const thirdSigner = await seedUser();
const { user, team } = await seedUser();
const { user: firstSigner } = await seedUser();
const { user: secondSigner } = await seedUser();
const { user: thirdSigner } = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [firstSigner, secondSigner, thirdSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }, { signingOrder: 3 }],
updateDocumentOptions: {
@ -109,12 +110,13 @@ test('[NEXT_RECIPIENT_DICTATION]: should allow updating next recipient when dict
});
test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { user, team } = await seedUser();
const { user: firstSigner } = await seedUser();
const { user: secondSigner } = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
@ -194,12 +196,13 @@ test('[NEXT_RECIPIENT_DICTATION]: should not show dictation UI when disabled', a
});
test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async ({ page }) => {
const user = await seedUser();
const firstSigner = await seedUser();
const secondSigner = await seedUser();
const { user, team } = await seedUser();
const { user: firstSigner } = await seedUser();
const { user: secondSigner } = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [firstSigner, secondSigner],
recipientsCreateOptions: [{ signingOrder: 1 }, { signingOrder: 2 }],
updateDocumentOptions: {
@ -278,13 +281,14 @@ test('[NEXT_RECIPIENT_DICTATION]: should work with parallel signing flow', async
test('[NEXT_RECIPIENT_DICTATION]: should allow assistant to dictate next signer', async ({
page,
}) => {
const user = await seedUser();
const assistant = await seedUser();
const signer = await seedUser();
const thirdSigner = await seedUser();
const { user, team } = await seedUser();
const { user: assistant } = await seedUser();
const { user: signer } = await seedUser();
const { user: thirdSigner } = await seedUser();
const { recipients, document } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: [assistant, signer, thirdSigner],
recipientsCreateOptions: [
{ signingOrder: 1, role: RecipientRole.ASSISTANT },

View File

@ -5,137 +5,18 @@ import {
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe('[EE_ONLY]', () => {
// eslint-disable-next-line turbo/no-undeclared-env-vars
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[DOCUMENT_FLOW] add action auth settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
});
test('[DOCUMENT_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(owner, {
createDocumentOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
});
test('[DOCUMENT_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(teamMemberUser);
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
});
});
test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Set title.
@ -143,7 +24,7 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.
@ -163,21 +44,21 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
});
test('[DOCUMENT_FLOW]: title should be disabled depending on document status', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const pendingDocument = await seedPendingDocument(user, []);
const draftDocument = await seedDraftDocument(user, []);
const pendingDocument = await seedPendingDocument(user, team.id, []);
const draftDocument = await seedDraftDocument(user, team.id, []);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${pendingDocument.id}/edit`,
redirectPath: `/t/${team.url}/documents/${pendingDocument.id}/edit`,
});
// Should be disabled for pending documents.
await expect(page.getByLabel('Title')).toBeDisabled();
// Should be enabled for draft documents.
await page.goto(`/documents/${draftDocument.id}/edit`);
await page.goto(`/t/${team.url}/documents/${draftDocument.id}/edit`);
await expect(page.getByLabel('Title')).toBeEnabled();
});

View File

@ -1,71 +1,18 @@
import { expect, test } from '@playwright/test';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe('[EE_ONLY]', () => {
// eslint-disable-next-line turbo/no-undeclared-env-vars
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[DOCUMENT_FLOW] add EE settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email').nth(1).fill('recipient2@documenso.com');
await page.getByLabel('Name').nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').check();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
});
});
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Save the settings by going to the next step.

View File

@ -35,11 +35,12 @@ const getDocumentByToken = async (token: string) => {
};
test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/documents`,
});
// Upload document.
@ -58,17 +59,17 @@ test('[DOCUMENT_FLOW]: should be able to upload a PDF document', async ({ page }
await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf'));
// Wait to be redirected to the edit page.
await page.waitForURL(/\/documents\/\d+/);
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
});
test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
const documentTitle = `example-${Date.now()}.pdf`;
@ -114,7 +115,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
@ -123,13 +124,13 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
const documentTitle = `example-${Date.now()}.pdf`;
@ -199,7 +200,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
@ -208,13 +209,13 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients with different roles', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Set title
@ -297,7 +298,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
// Assert document was created
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
@ -306,13 +307,13 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
test('[DOCUMENT_FLOW]: should not be able to create a document without signatures', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
const documentTitle = `example-${Date.now()}.pdf`;
@ -342,10 +343,11 @@ test('[DOCUMENT_FLOW]: should not be able to create a document without signature
});
test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
teamId: team.id,
recipients: ['user@documenso.com', 'approver@documenso.com'],
recipientsCreateOptions: [
{
@ -393,13 +395,13 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
const documentTitle = `example-${Date.now()}.pdf`;
@ -430,12 +432,12 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
await page.waitForURL(`/t/${team.url}/documents`);
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(/\/documents\/\d+/);
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
const url = page.url().split('/');
const documentId = url[url.length - 1];
@ -467,11 +469,12 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
});
test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const now = DateTime.utc();
const { document, recipients } = await seedPendingDocumentWithFullFields({
teamId: team.id,
owner: user,
recipients: ['user1@example.com'],
fields: [FieldType.DATE],
@ -516,13 +519,13 @@ test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', asyn
test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recipients in sequential order', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
const { user, team } = await seedUser();
const document = await seedBlankDocument(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
const documentTitle = `Sequential-Signing-${Date.now()}.pdf`;
@ -579,7 +582,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.waitForTimeout(2500);
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
@ -630,7 +633,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
}
// Wait for the document to be signed.
await page.waitForTimeout(5000);
await page.waitForTimeout(10000);
const finalDocument = await prisma.document.findFirst({
where: { id: createdDocument?.id },
@ -642,9 +645,10 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({
page,
}) => {
const user = await seedUser();
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
teamId: team.id,
owner: user,
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
fields: [FieldType.SIGNATURE],

View File

@ -13,16 +13,20 @@ import { checkDocumentTabCount } from '../fixtures/documents';
test.describe.configure({ mode: 'serial' });
const seedDeleteDocumentsTestRequirements = async () => {
const [sender, recipientA, recipientB] = await Promise.all([seedUser(), seedUser(), seedUser()]);
const [sender, recipientA, recipientB] = await Promise.all([
seedUser({ setTeamEmailAsOwner: true }),
seedUser({ setTeamEmailAsOwner: true }),
seedUser({ setTeamEmailAsOwner: true }),
]);
const [draftDocument, pendingDocument, completedDocument] = await Promise.all([
seedDraftDocument(sender, [recipientA, recipientB], {
seedDraftDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
createDocumentOptions: { title: 'Document 1 - Draft' },
}),
seedPendingDocument(sender, [recipientA, recipientB], {
seedPendingDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
createDocumentOptions: { title: 'Document 1 - Pending' },
}),
seedCompletedDocument(sender, [recipientA, recipientB], {
seedCompletedDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
createDocumentOptions: { title: 'Document 1 - Completed' },
}),
]);
@ -41,7 +45,8 @@ test('[DOCUMENTS]: seeded documents should be visible', async ({ page }) => {
await apiSignin({
page,
email: sender.email,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
@ -53,7 +58,8 @@ test('[DOCUMENTS]: seeded documents should be visible', async ({ page }) => {
for (const recipient of recipients) {
await apiSignin({
page,
email: recipient.email,
email: recipient.user.email,
redirectPath: `/t/${recipient.team.url}/documents`,
});
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
@ -72,7 +78,8 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
await apiSignin({
page,
email: sender.email,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
// Open document action menu.
@ -95,7 +102,8 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
for (const recipient of recipients) {
await apiSignin({
page,
email: recipient.email,
email: recipient.user.email,
redirectPath: `/t/${recipient.team.url}/documents`,
});
await expect(page.getByRole('link', { name: 'Document 1 - Completed' })).toBeVisible();
@ -113,7 +121,8 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
await apiSignin({
page,
email: sender.email,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
// Open document action menu.
@ -135,6 +144,7 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
email: recipient.email,
});
// Check dashboard inbox.
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
await apiSignout({ page });
}
@ -145,7 +155,8 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
await apiSignin({
page,
email: sender.email,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
// Open document action menu.
@ -174,7 +185,8 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
await apiSignin({
page,
email: sender.email,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
// Open document action menu.
@ -205,7 +217,8 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
await apiSignin({
page,
email: sender.email,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
// Open document action menu.
@ -231,7 +244,8 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
await apiSignout({ page });
await apiSignin({
page,
email: recipients[0].email,
email: recipients[0].user.email,
redirectPath: `/t/${recipients[0].team.url}/documents`,
});
// Check document counts.
@ -252,7 +266,8 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
await apiSignin({
page,
email: recipientA.email,
email: recipientA.user.email,
redirectPath: `/t/${recipientA.team.url}/documents`,
});
// Open document action menu.
@ -268,10 +283,10 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
}).toPass();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
await page.waitForTimeout(1000);
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
await page.waitForTimeout(2000);
await expect(async () => {
await page
@ -285,8 +300,10 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
}).toPass();
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).click();
await page.getByRole('button', { name: 'Hide' }).click();
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
await page.waitForTimeout(2000);
// Check document counts.
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
@ -301,7 +318,8 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
await apiSignout({ page });
await apiSignin({
page,
email: sender.email,
email: sender.user.email,
redirectPath: `/t/${sender.team.url}/documents`,
});
// Check document counts for sender.
@ -315,7 +333,8 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
await apiSignout({ page });
await apiSignin({
page,
email: recipientB.email,
email: recipientB.user.email,
redirectPath: `/t/${recipientB.team.url}/documents`,
});
// Check document counts for other recipient.

View File

@ -14,12 +14,15 @@ import { signSignaturePad } from '../fixtures/signature';
test.describe('Signing Certificate Tests', () => {
test('individual document should always include signing certificate', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser({
isPersonalOrganisation: true,
});
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['signer@example.com'],
fields: [FieldType.SIGNATURE],
teamId: team.id,
});
const documentData = await prisma.documentData
@ -46,9 +49,11 @@ test.describe('Signing Certificate Tests', () => {
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.getByRole('button', { name: 'Sign' }).click({ force: true });
await page.waitForURL(`/sign/${recipient.token}/complete`);
await page.waitForTimeout(10000);
await expect(async () => {
const { status } = await getDocumentByToken({
token: recipient.token,
@ -76,20 +81,28 @@ test.describe('Signing Certificate Tests', () => {
test('team document with signing certificate enabled should include certificate', async ({
page,
}) => {
const team = await seedTeam();
const { owner, team } = await seedTeam();
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: team.owner,
owner: owner,
recipients: ['signer@example.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: {
teamId: team.id,
teamId: team.id,
});
const teamSettingsId = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: team.id,
},
},
});
await prisma.teamGlobalSettings.create({
await prisma.teamGlobalSettings.update({
where: {
id: teamSettingsId.id,
},
data: {
teamId: team.id,
includeSigningCertificate: true,
},
});
@ -148,20 +161,28 @@ test.describe('Signing Certificate Tests', () => {
test('team document with signing certificate disabled should not include certificate', async ({
page,
}) => {
const team = await seedTeam();
const { owner, team } = await seedTeam();
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: team.owner,
owner: owner,
recipients: ['signer@example.com'],
fields: [FieldType.SIGNATURE],
updateDocumentOptions: {
teamId: team.id,
teamId: team.id,
});
const teamSettingsId = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: team.id,
},
},
});
await prisma.teamGlobalSettings.create({
await prisma.teamGlobalSettings.update({
where: {
id: teamSettingsId.id,
},
data: {
teamId: team.id,
includeSigningCertificate: false,
},
});
@ -218,16 +239,22 @@ test.describe('Signing Certificate Tests', () => {
});
test('team can toggle signing certificate setting', async ({ page }) => {
const team = await seedTeam();
const { owner, team } = await seedTeam();
await apiSignin({
page,
email: team.owner.email,
email: owner.email,
redirectPath: `/t/${team.url}/settings/preferences`,
});
// Toggle signing certificate setting
await page.getByLabel('Include the Signing Certificate in the Document').click();
await page
.getByRole('group')
.locator('div')
.filter({ hasText: 'Include the Signing' })
.getByRole('combobox')
.click();
await page.getByRole('option', { name: 'No' }).click();
await page
.getByRole('button', { name: /Update/ })
.first()
@ -244,7 +271,13 @@ test.describe('Signing Certificate Tests', () => {
expect(updatedTeam.teamGlobalSettings?.includeSigningCertificate).toBe(false);
// Toggle the setting back to true
await page.getByLabel('Include the Signing Certificate in the Document').click();
await page
.getByRole('group')
.locator('div')
.filter({ hasText: 'Include the Signing' })
.getByRole('combobox')
.click();
await page.getByRole('option', { name: 'Yes' }).click();
await page
.getByRole('button', { name: /Update/ })
.first()

View File

@ -17,7 +17,7 @@ export const apiSignin = async ({
page,
email = 'example@documenso.com',
password = 'password',
redirectPath = '/documents',
redirectPath = '/',
}: LoginOptions) => {
const { request } = page.context();

View File

@ -0,0 +1,9 @@
import { type Page, expect } from '@playwright/test';
export const expectTextToBeVisible = async (page: Page, text: string) => {
await expect(page.getByText(text).first()).toBeVisible();
};
export const expectTextToNotBeVisible = async (page: Page, text: string) => {
await expect(page.getByText(text).first()).not.toBeVisible();
};

View File

@ -1,866 +0,0 @@
import { expect, test } from '@playwright/test';
import path from 'node:path';
import { FolderType } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('create folder button is visible on documents page', async ({ page }) => {
const user = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: '/',
});
await expect(page.getByRole('button', { name: 'Create Folder' })).toBeVisible();
});
test('user can create a document folder', async ({ page }) => {
const user = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: '/',
});
await page.getByRole('button', { name: 'Create Folder' }).click();
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
await page.getByLabel('Folder name').fill('My folder');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('My folder')).toBeVisible();
await page.goto('/documents');
await expect(page.locator('div').filter({ hasText: 'My folder' }).nth(3)).toBeVisible();
});
test('user can create a document subfolder inside a document folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/f/${folder.id}`,
});
await expect(page.getByText('Client Contracts')).toBeVisible();
await page.getByRole('button', { name: 'Create Folder' }).click();
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
await page.getByLabel('Folder name').fill('Invoices');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('Invoices')).toBeVisible();
});
test('user can create a document inside a document folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/f/${folder.id}`,
});
const fileInput = page.locator('input[type="file"]').nth(1);
await fileInput.waitFor({ state: 'attached' });
await fileInput.setInputFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
);
await page.waitForTimeout(3000);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
await page.goto(`/documents/f/${folder.id}`);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
});
test('user can pin a document folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page.reload();
await expect(page.locator('svg.text-documenso.h-3.w-3')).toBeVisible();
});
test('user can unpin a document folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
pinned: true,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page.reload();
await expect(page.locator('svg.text-documenso.h-3.w-3')).not.toBeVisible();
});
test('user can rename a document folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Archive');
await page.getByRole('button', { name: 'Save Changes' }).click();
await expect(page.getByText('Archive')).toBeVisible();
});
test('document folder visibility is not visible to user', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
});
test('document folder can be moved to another document folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Clients',
},
});
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Clients' }).click();
await page.getByRole('button', { name: 'Move Folder' }).click();
await page.waitForTimeout(1000);
await page.goto(`/documents/f/${folder.id}`);
await expect(page.getByText('Contracts')).toBeVisible();
});
test('document folder can be moved to the root', async ({ page }) => {
const user = await seedUser();
const parentFolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Clients',
},
});
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contracts',
parentId: parentFolder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByText('Clients').click();
await page.getByRole('button', { name: '•••' }).nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Root' }).click();
await page.getByRole('button', { name: 'Move Folder' }).click();
await page.waitForTimeout(1000);
await page.goto('/documents');
await expect(page.getByText('Clients')).toBeVisible();
});
test('document folder and its contents can be deleted', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Proposals',
},
});
const proposal = await seedBlankDocument(user, {
createDocumentOptions: {
title: 'Proposal 1',
folderId: folder.id,
},
});
const reportsFolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Reports',
parentId: folder.id,
},
});
const report = await seedBlankDocument(user, {
createDocumentOptions: {
title: 'Report 1',
folderId: reportsFolder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('textbox').fill(`delete ${folder.name}`);
await page.getByRole('button', { name: 'Delete' }).click();
await page.goto('/documents');
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
await expect(page.getByText(proposal.title)).not.toBeVisible();
await page.goto(`/documents/f/${folder.id}`);
await expect(page.getByText(report.title)).not.toBeVisible();
await expect(page.locator('div').filter({ hasText: reportsFolder.name })).not.toBeVisible();
});
test('user can move a document to a document folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Proposals',
},
});
await seedBlankDocument(user, {
createDocumentOptions: {
title: 'Proposal 1',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Proposals' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.waitForTimeout(1000);
await page.goto(`/documents/f/${folder.id}`);
await expect(page.getByText('Proposal 1')).toBeVisible();
});
test('user can move a document from folder to the root', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Proposals',
},
});
await seedBlankDocument(user, {
createDocumentOptions: {
title: 'Proposal 1',
folderId: folder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
await page.getByText('Proposals').click();
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Root' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.waitForTimeout(1000);
await page.goto('/documents');
await expect(page.getByText('Proposal 1')).toBeVisible();
});
test('create folder button is visible on templates page', async ({ page }) => {
const user = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await expect(page.getByRole('button', { name: 'Create folder' })).toBeVisible();
});
test('user can create a template folder', async ({ page }) => {
const user = await seedUser();
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: 'Create folder' }).click();
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
await page.getByLabel('Folder name').fill('My template folder');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('My template folder')).toBeVisible();
await page.goto('/templates');
await expect(page.locator('div').filter({ hasText: 'My template folder' }).nth(3)).toBeVisible();
});
test('user can create a template subfolder inside a template folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/f/${folder.id}`,
});
await expect(page.getByText('Client Templates')).toBeVisible();
await page.getByRole('button', { name: 'Create folder' }).click();
await expect(page.getByRole('dialog', { name: 'Create New folder' })).toBeVisible();
await page.getByLabel('Folder name').fill('Contract Templates');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('Contract Templates')).toBeVisible();
});
test('user can create a template inside a template folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/f/${folder.id}`,
});
await expect(page.getByText('Client Templates')).toBeVisible();
await page.getByRole('button', { name: 'New Template' }).click();
// await expect(page.getByRole('dialog', { name: 'New Template' })).toBeVisible();
await page
.locator('div')
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
.nth(2)
.click();
await page.locator('input[type="file"]').waitFor({ state: 'attached' });
await page
.locator('input[type="file"]')
.setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForTimeout(1000);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
await page.goto(`/templates/f/${folder.id}`);
await expect(page.getByText('documenso-supporter-pledge.pdf')).toBeVisible();
});
test('user can pin a template folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page.reload();
await expect(page.locator('svg.text-documenso.h-3.w-3')).toBeVisible();
});
test('user can unpin a template folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
pinned: true,
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page.reload();
await expect(page.locator('svg.text-documenso.h-3.w-3')).not.toBeVisible();
});
test('user can rename a template folder', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Updated Template Folder');
await page.getByRole('button', { name: 'Save Changes' }).click();
await expect(page.getByText('Updated Template Folder')).toBeVisible();
});
test('template folder visibility is not visible to user', async ({ page }) => {
const user = await seedUser();
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
});
test('template folder can be moved to another template folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Client Templates' }).click();
await page.getByRole('button', { name: 'Move Folder' }).click();
await page.waitForTimeout(1000);
await page.goto(`/templates/f/${folder.id}`);
await expect(page.getByText('Contract Templates')).toBeVisible();
});
test('template folder can be moved to the root', async ({ page }) => {
const user = await seedUser();
const parentFolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
parentId: parentFolder.id,
type: FolderType.TEMPLATE,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByText('Client Templates').click();
await page.getByRole('button', { name: '•••' }).nth(0).click();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Root' }).click();
await page.getByRole('button', { name: 'Move Folder' }).click();
await page.waitForTimeout(1000);
await page.goto('/templates');
await expect(page.getByText('Contract Templates')).toBeVisible();
});
test('template folder and its contents can be deleted', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Proposal Templates',
type: FolderType.TEMPLATE,
},
});
const template = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Proposal Template 1',
folderId: folder.id,
},
});
const subfolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Report Templates',
parentId: folder.id,
type: FolderType.TEMPLATE,
},
});
const reportTemplate = await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Report Template 1',
folderId: subfolder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByRole('button', { name: '•••' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('textbox').fill(`delete ${folder.name}`);
await page.getByRole('button', { name: 'Delete' }).click();
await page.goto('/templates');
await expect(page.locator('div').filter({ hasText: folder.name })).not.toBeVisible();
await expect(page.getByText(template.title)).not.toBeVisible();
await page.goto(`/templates/f/${folder.id}`);
await expect(page.getByText(reportTemplate.title)).not.toBeVisible();
await expect(page.locator('div').filter({ hasText: subfolder.name })).not.toBeVisible();
});
test('user can navigate between template folders', async ({ page }) => {
const user = await seedUser();
const parentFolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
const subfolder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Contract Templates',
parentId: parentFolder.id,
type: FolderType.TEMPLATE,
},
});
await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Contract Template 1',
folderId: subfolder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByText('Client Templates').click();
await expect(page.getByText('Contract Templates')).toBeVisible();
await page.getByText('Contract Templates').click();
await expect(page.getByText('Contract Template 1')).toBeVisible();
await page.getByRole('button', { name: parentFolder.name }).click();
await expect(page.getByText('Contract Templates')).toBeVisible();
await page.getByRole('button', { name: subfolder.name }).click();
await expect(page.getByText('Contract Template 1')).toBeVisible();
});
test('user can move a template to a template folder', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Proposal Template 1',
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await expect(async () => {
await page.getByTestId('template-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Client Templates' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.goto(`/templates/f/${folder.id}`);
await page.waitForTimeout(1000);
await expect(page.getByText('Proposal Template 1')).toBeVisible();
});
test('user can move a template from a folder to the root', async ({ page }) => {
const user = await seedUser();
const folder = await seedBlankFolder(user, {
createFolderOptions: {
name: 'Client Templates',
type: FolderType.TEMPLATE,
},
});
await seedBlankTemplate(user, {
createTemplateOptions: {
title: 'Proposal Template 1',
folderId: folder.id,
},
});
await apiSignin({
page,
email: user.email,
redirectPath: '/templates',
});
await page.getByText('Client Templates').click();
await expect(async () => {
await page.getByTestId('template-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: 'Root' }).click();
await page.getByRole('button', { name: 'Move' }).click();
await page.waitForTimeout(1000);
await page.goto('/templates');
await expect(page.getByText('Proposal Template 1')).toBeVisible();
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,565 @@
import { expect, test } from '@playwright/test';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { nanoid } from '@documenso/lib/universal/id';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectTextToBeVisible, expectTextToNotBeVisible } from '../fixtures/generic';
test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/settings/organisations`,
});
await expect(page.getByRole('button', { name: 'Leave' })).toBeDisabled();
await page.getByRole('link', { name: 'Manage' }).click();
await page.waitForURL(`/o/${organisation.url}/settings/general`);
await page.getByRole('button', { name: 'Delete' }).click();
await page
.getByLabel(`Confirm by typing delete ${organisation.name}`)
.fill(`delete ${organisation.name}`);
await page.getByRole('button', { name: 'Delete' }).click();
await page.waitForURL(`/settings/organisations`);
await expectTextToBeVisible(page, 'No results found');
await page.getByRole('button', { name: 'Create organisation' }).click();
await page.getByLabel('Organisation Name*').fill('test');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('Your organisation has been created').first()).toBeVisible();
await page.reload();
await page.getByRole('row').filter({ hasText: 'test' }).getByRole('link').nth(1).click();
});
test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
const { user, organisation } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/general`,
});
const updatedOrganisationId = `organisation-${Date.now()}`;
// Update team.
await page.getByLabel('Organisation Name*').click();
await page.getByLabel('Organisation Name*').clear();
await page.getByLabel('Organisation Name*').fill(updatedOrganisationId);
await page.getByLabel('Organisation URL*').click();
await page.getByLabel('Organisation URL*').clear();
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
await page.getByRole('button', { name: 'Update organisation' }).click();
// Check we have been redirected to the new organisation URL and the name is updated.
await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`);
});
test('[ORGANISATIONS]: inherit members', async ({ page }) => {
const {
user,
organisation,
team: teamWithInheritMembers,
} = await seedUser({
isPersonalOrganisation: false,
});
const teamWithoutInheritedMembersUrl = `team-${nanoid()}`;
await createTeam({
userId: user.id,
teamName: 'No inherit',
teamUrl: teamWithoutInheritedMembersUrl,
organisationId: organisation.id,
inheritMembers: false,
});
const memberEmail = `member-${nanoid()}@test.documenso.com`;
const memberEmail2 = `member-2-${nanoid()}@test.documenso.com`;
const memberEmail3 = `member-3-${nanoid()}@test.documenso.com`;
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
const adminEmail = `admin-${nanoid()}@test.documenso.com`;
const ownerEmail = user.email;
await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Member 1',
organisationRole: 'MEMBER',
},
{
email: memberEmail2,
name: 'Member 2',
organisationRole: 'MEMBER',
},
{
email: memberEmail3,
name: 'Member 3',
organisationRole: 'MEMBER',
},
{
email: managerEmail,
name: 'Manager',
organisationRole: 'MANAGER',
},
{
email: adminEmail,
name: 'Admin',
organisationRole: 'ADMIN',
},
],
organisationId: organisation.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${teamWithoutInheritedMembersUrl}/settings/members`,
});
// Check from admin POV that member counts are correct
// You should only see the manager/admins from the organisation in this table.
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail),
).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail })).not.toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail2 })).not.toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail3 })).not.toBeVisible();
// Explicitly add a member to the team.
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Member 1' }).first().click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Add Members' }).click();
await expect(
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail),
).toBeVisible();
await page.goto(`/t/${teamWithInheritMembers.url}/settings/members`);
// Check from member POV that member counts are correct for inherit members team.
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail2),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Member' }).getByText(memberEmail3),
).toBeVisible();
// Disable inherit mode.
await page.goto(`/t/${teamWithInheritMembers.url}/settings/groups`);
await page.getByRole('button', { name: 'Disable access' }).click();
await page.getByRole('button', { name: 'Disable' }).click();
await expect(page.getByText('Enable Access').first()).toBeVisible();
// Expect the inherited members to disappear
await page.goto(`/t/${teamWithInheritMembers.url}/settings/members`);
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(managerEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(adminEmail),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Admin' }).getByText(ownerEmail),
).toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail })).not.toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail2 })).not.toBeVisible();
await expect(page.getByRole('row').filter({ hasText: memberEmail3 })).not.toBeVisible();
});
test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
const {
user,
organisation,
team: teamInherit,
} = await seedUser({
isPersonalOrganisation: false,
});
const teamInheritName = teamInherit.name;
const teamA = `team-${nanoid()}`;
const teamAName = `TeamA - No inherit`;
const teamB = `team-${nanoid()}`;
const teamBName = `TeamB - No inherit`;
await createTeam({
userId: user.id,
teamName: teamAName,
teamUrl: teamA,
organisationId: organisation.id,
inheritMembers: false,
});
await createTeam({
userId: user.id,
teamName: teamBName,
teamUrl: teamB,
organisationId: organisation.id,
inheritMembers: false,
});
const memberEmail1 = `member-1-${nanoid()}@test.documenso.com`;
const memberEmail2 = `member-2-${nanoid()}@test.documenso.com`;
const memberEmail3 = `member-3-${nanoid()}@test.documenso.com`;
const memberEmail4 = `member-4-${nanoid()}@test.documenso.com`;
const memberEmail5 = `member-5-${nanoid()}@test.documenso.com`;
const memberEmail6 = `member-6-${nanoid()}@test.documenso.com`;
const adminEmail1 = `admin-1-${nanoid()}@test.documenso.com`;
const adminEmail2 = `admin-2-${nanoid()}@test.documenso.com`;
const adminEmail3 = `admin-3-${nanoid()}@test.documenso.com`;
const ownerEmail = user.email;
await seedOrganisationMembers({
members: [
{
email: memberEmail1,
name: 'Member1',
organisationRole: 'MEMBER',
},
{
email: memberEmail2,
name: 'Member2',
organisationRole: 'MEMBER',
},
{
email: memberEmail3,
name: 'Member3',
organisationRole: 'MEMBER',
},
{
email: memberEmail4,
name: 'Member4',
organisationRole: 'MEMBER',
},
{
email: memberEmail5,
name: 'Member5',
organisationRole: 'MEMBER',
},
{
email: memberEmail6,
name: 'Member6',
organisationRole: 'MEMBER',
},
{
email: adminEmail1,
name: 'Admin1',
organisationRole: 'ADMIN',
},
{
email: adminEmail2,
name: 'Admin2',
organisationRole: 'ADMIN',
},
{
email: adminEmail3,
name: 'Admin3',
organisationRole: 'ADMIN',
},
],
organisationId: organisation.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/groups`,
});
// Create a custom group A with 3 members "ORGANISATION ADMIN" to check that they get the correct roles.
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByRole('option', { name: 'Member1' }).click();
await page.getByRole('option', { name: 'Member2' }).click();
await page.getByRole('option', { name: 'Member3' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm org roles have been applied to these members.
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail1),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail2),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(memberEmail3),
).toBeVisible();
// Test updating the group.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('link', { name: 'Manage' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
await page.getByRole('option', { name: 'Organisation Member' }).click();
await page.getByRole('combobox').filter({ hasText: 'Member1, Member2, Member3' }).click();
await page.getByRole('option', { name: 'Member3' }).click();
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByText('Group has been updated successfully').first()).toBeVisible();
await page.goto(`/o/${organisation.url}/settings/groups`);
// Create a custom member group with the 3 admins to check that they still get the ADMIN roles.
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_ADMINS');
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByRole('option', { name: 'Admin1' }).click();
await page.getByRole('option', { name: 'Admin2' }).click();
await page.getByRole('option', { name: 'Admin3' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
await page.goto(`/o/${organisation.url}/settings/members`);
// Confirm admins still get admin roles.
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail1),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail2),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Organisation Admin' }).getByText(adminEmail3),
).toBeVisible();
// Create another custom group with 3 members with "ORGANISATION MEMBER" role.
await page.goto(`/o/${organisation.url}/settings/groups`);
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByRole('option', { name: 'Member4' }).click();
await page.getByRole('option', { name: 'Member5' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
// Assign CUSTOM_GROUP_A to TeamA
await page.goto(`/t/${teamA}/settings/groups`);
await page.getByRole('button', { name: 'Add groups' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'CUSTOM_GROUP_A', exact: true }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
await page.getByRole('button', { name: 'Create Groups' }).click();
await expect(page.getByText('Team members have been added').first()).toBeVisible();
// Assign CUSTOM_GROUP_B to TeamA
await page.goto(`/t/${teamA}/settings/groups`);
await page.getByRole('button', { name: 'Add groups' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'CUSTOM_GROUP_B', exact: true }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
await page.getByRole('button', { name: 'Create Groups' }).click();
await expect(page.getByText('Team members have been added').first()).toBeVisible();
// Update CUSTOM_GROUP_B
await page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Update role' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Team Admin' }).click();
await page.getByRole('button', { name: 'Update' }).click();
await expectTextToBeVisible(page, 'You have updated the team group');
await expect(page.getByText('Team Admin').first()).toBeVisible();
await page.reload();
// Delete CUSTOM_GROUP_B
await page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expectTextToBeVisible(page, 'You have successfully removed this group from the team.');
await expect(page.getByText('CUSTOM_GROUP_B')).not.toBeVisible();
// Navigate to team members and validate members are there.
await page.goto(`/t/${teamA}/settings/members`);
await expect(
page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail1),
).toBeVisible();
await expect(
page.getByRole('row').filter({ hasText: 'Team Manager' }).getByText(memberEmail2),
).toBeVisible();
// Member 1 should see inherit team and teamA
await apiSignout({ page });
await apiSignin({ page, email: memberEmail1, redirectPath: `/o/${organisation.url}` });
await expectTextToBeVisible(page, teamInheritName);
await expectTextToBeVisible(page, teamAName);
await expectTextToNotBeVisible(page, teamBName);
// Member 3 should only see inherit team
await apiSignout({ page });
await apiSignin({ page, email: memberEmail3, redirectPath: `/o/${organisation.url}` });
await expectTextToBeVisible(page, teamInheritName);
await expectTextToNotBeVisible(page, teamAName);
await expectTextToNotBeVisible(page, teamBName);
// Admin 1 should see all teams.
await apiSignout({ page });
await apiSignin({ page, email: adminEmail1, redirectPath: `/o/${organisation.url}` });
await expectTextToBeVisible(page, teamInheritName);
await expectTextToBeVisible(page, teamAName);
await expectTextToBeVisible(page, teamBName);
});
test('[ORGANISATIONS]: member invites', async ({ page }) => {
const { user, organisation, team } = await seedUser({
inheritMembers: false,
});
const { user: user2 } = await seedUser({
isPersonalOrganisation: false,
});
const { user: user3 } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/members`,
});
await page.getByRole('button', { name: 'Invite member' }).click();
await page.getByRole('textbox', { name: 'Email address *' }).click();
await page.getByRole('textbox', { name: 'Email address *' }).fill(user2.email);
await page.getByRole('button', { name: 'Add more' }).click();
await page.locator('input[name="invitations\\.1\\.email"]').fill(user3.email);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('tab', { name: 'Pending' }).click();
await expect(page.getByText(user2.email)).toBeVisible();
await expect(page.getByText(user3.email)).toBeVisible();
await page.getByRole('row', { name: user3.email }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await expect(page.getByText('Invitation has been deleted').first()).toBeVisible();
await expect(page.getByText(user3.email)).not.toBeVisible();
// Sign in as member and accept invite
await apiSignout({ page });
await apiSignin({ page, email: user2.email, redirectPath: `/settings/organisations` });
await page.getByRole('button', { name: 'View invites' }).click();
await page.getByRole('button', { name: 'Accept' }).click();
await expect(page.getByText('Invitation accepted').first()).toBeVisible();
// Sign back in as org owner.
await apiSignout({ page });
await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/settings/members` });
// Expect 1 member in team.
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(user2.email)).not.toBeVisible();
// Add member to team.
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: user2.name ?? '' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Add Members' }).click();
// Expect 2 members to be visible.
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(user2.email)).toBeVisible();
await page.getByRole('row', { name: user2.email }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('You have successfully removed').first()).toBeVisible();
// Expect 1 member in team.
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(user2.email)).not.toBeVisible();
// Expect 2 members in organisation.
await page.goto(`/o/${organisation.url}/settings/members`);
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(user2.email)).toBeVisible();
await page.getByRole('row', { name: user2.email }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('You have successfully removed this user').first()).toBeVisible();
// Expect 1 member in organisation.
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(user2.email)).not.toBeVisible();
});
test('[ORGANISATIONS]: leave organisation', async ({ page }) => {
const { organisation } = await seedUser({
isPersonalOrganisation: false,
});
const memberEmail = `member-${nanoid()}@test.documenso.com`;
await seedOrganisationMembers({
members: [
{
email: memberEmail,
name: 'Member 1',
organisationRole: 'MEMBER',
},
],
organisationId: organisation.id,
});
await apiSignin({
page,
email: memberEmail,
redirectPath: `/settings/organisations`,
});
await page.getByRole('button', { name: 'Leave' }).click();
await page.getByRole('button', { name: 'Leave' }).click();
await expect(
page.getByText('You have successfully left this organisation').first(),
).toBeVisible();
await expect(page.getByText('No results found').first()).toBeVisible();
});

View File

@ -0,0 +1,113 @@
import { expect, test } from '@playwright/test';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '@documenso/prisma/client';
import { seedTeamDocumentWithMeta } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[ORGANISATIONS]: manage preferences', async ({ page }) => {
const { user, organisation, team } = await seedUser({
isPersonalOrganisation: false,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/o/${organisation.url}/settings/preferences`,
});
// Update document preferences.
await page.getByRole('combobox').filter({ hasText: 'Everyone can access and view' }).click();
await page.getByRole('option', { name: 'Only managers and above can' }).click();
await page.getByRole('combobox').filter({ hasText: 'English' }).click();
await page.getByRole('option', { name: 'German' }).click();
await page.getByTestId('signature-types-combobox').click();
await page.getByRole('option', { name: 'Draw' }).click();
await page.getByRole('option', { name: 'Upload' }).click();
await page.getByRole('combobox').nth(3).click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('combobox').filter({ hasText: 'Yes' }).click();
await page.getByRole('option', { name: 'No' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
// Update branding.
await page.getByTestId('enable-branding').click();
await page.getByRole('option', { name: 'Yes' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).click();
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
await page.getByRole('textbox', { name: 'Brand Details' }).click();
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
await page.getByRole('button', { name: 'Update' }).nth(1).click();
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
const teamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have inherited these values.
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
expect(teamSettings.documentLanguage).toEqual('de');
expect(teamSettings.includeSenderDetails).toEqual(false);
expect(teamSettings.includeSigningCertificate).toEqual(false);
expect(teamSettings.typedSignatureEnabled).toEqual(true);
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
expect(teamSettings.drawSignatureEnabled).toEqual(false);
expect(teamSettings.brandingEnabled).toEqual(true);
expect(teamSettings.brandingUrl).toEqual('https://documenso.com');
expect(teamSettings.brandingCompanyDetails).toEqual('BrandDetails');
// Edit the team settings
await page.goto(`/t/${team.url}/settings/preferences`);
await page
.getByRole('group')
.locator('div')
.filter({
hasText: 'Default Document Visibility',
})
.getByRole('combobox')
.click();
await page.getByRole('option', { name: 'Everyone can access and view' }).click();
await page
.getByRole('group')
.locator('div')
.filter({ hasText: 'Default Document Language' })
.getByRole('combobox')
.click();
await page.getByRole('option', { name: 'Polish' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
const updatedTeamSettings = await getTeamSettings({
teamId: team.id,
});
// Check that the team settings have inherited/overriden the correct values.
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
expect(updatedTeamSettings.documentLanguage).toEqual('pl');
expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
const document = await seedTeamDocumentWithMeta(team);
const documentMeta = await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: document.id,
},
});
// Confirm the settings have been applied to a newly created document.
expect(document.visibility).toEqual(DocumentVisibility.EVERYONE);
expect(documentMeta.typedSignatureEnabled).toEqual(true);
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
expect(documentMeta.drawSignatureEnabled).toEqual(false);
expect(documentMeta.language).toEqual('pl');
});

View File

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

View File

@ -1,13 +1,13 @@
import { test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { expectTextToBeVisible } from '../fixtures/generic';
test('[TEAMS]: create team', async ({ page }) => {
const user = await seedUser();
const { user, organisation } = await seedUser();
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
@ -17,7 +17,7 @@ test('[TEAMS]: create team', async ({ page }) => {
await apiSignin({
page,
email: user.email,
redirectPath: '/settings/teams',
redirectPath: `/o/${organisation.url}/settings/teams`,
});
const teamId = `team-${Date.now()}`;
@ -34,38 +34,32 @@ test('[TEAMS]: create team', async ({ page }) => {
});
test('[TEAMS]: delete team', async ({ page }) => {
const team = await seedTeam();
const { user, team, organisation } = await seedUser();
await apiSignin({
page,
email: team.owner.email,
email: user.email,
redirectPath: `/t/${team.url}/settings`,
});
// Delete team.
await page.getByRole('button', { name: 'Delete team' }).click();
await page.getByLabel(`Confirm by typing delete ${team.url}`).fill(`delete ${team.url}`);
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByLabel(`Confirm by typing delete ${team.name}`).fill(`delete ${team.name}`);
await page.getByRole('button', { name: 'Delete' }).click();
// Check that we have been redirected to the teams page.
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/settings/teams`);
// Your team has been successfully deleted
await expectTextToBeVisible(page, 'Your team has been successfully deleted');
});
test('[TEAMS]: update team', async ({ page }) => {
const team = await seedTeam();
const { user, team } = await seedUser();
await apiSignin({
page,
email: team.owner.email,
email: user.email,
redirectPath: `/t/${team.url}/settings`,
});
// Navigate to create team page.
await page.getByTestId('menu-switcher').click();
await page.getByRole('menuitem', { name: 'Manage teams' }).click();
// Goto team settings page.
await page.getByRole('row').filter({ hasText: team.url }).getByRole('link').nth(1).click();
const updatedTeamId = `team-${Date.now()}`;
// Update team.

View File

@ -1,7 +1,10 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { DocumentStatus, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@ -9,38 +12,75 @@ import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
test('[TEAMS]: search respects team document visibility', async ({ page }) => {
const team = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
const { user: owner, organisation, team } = await seedUser();
const [adminUser, managerUser, memberUser] = await seedOrganisationMembers({
organisationId: organisation.id,
members: [
{
organisationRole: OrganisationMemberRole.ADMIN,
},
{
organisationRole: OrganisationMemberRole.MEMBER, // Org managers = team admins so need to workaround this.
},
{
organisationRole: OrganisationMemberRole.MEMBER,
},
],
});
const managerTeamGroup = await prisma.teamGroup.findFirstOrThrow({
where: {
teamId: team.id,
teamRole: TeamMemberRole.MANAGER,
},
include: {
organisationGroup: true,
},
});
const managerOrganisationMember = await prisma.organisationMember.findFirstOrThrow({
where: {
organisationId: organisation.id,
userId: managerUser.id,
},
});
await prisma.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
groupId: managerTeamGroup.organisationGroupId,
organisationMemberId: managerOrganisationMember.id,
},
});
await seedDocuments([
{
sender: team.owner,
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Searchable Document for Everyone',
},
},
{
sender: team.owner,
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Searchable Document for Managers',
},
},
{
sender: team.owner,
sender: owner,
teamId: team.id,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Searchable Document for Admins',
},
@ -70,26 +110,30 @@ test('[TEAMS]: search respects team document visibility', async ({ page }) => {
});
test('[TEAMS]: search does not reveal documents from other teams', async ({ page }) => {
const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments();
const { team: teamB } = await seedTeamDocuments();
const {
team: teamA,
teamOwner: teamAOwner,
teamMember2: teamAMember,
} = await seedTeamDocuments();
const { team: teamB, teamOwner: teamBOwner } = await seedTeamDocuments();
await seedDocuments([
{
sender: teamA.owner,
sender: teamAOwner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: teamA.id,
documentOptions: {
teamId: teamA.id,
visibility: 'EVERYONE',
title: 'Unique Team A Document',
},
},
{
sender: teamB.owner,
sender: teamBOwner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: teamB.id,
documentOptions: {
teamId: teamB.id,
visibility: 'EVERYONE',
title: 'Unique Team B Document',
},
@ -112,60 +156,19 @@ test('[TEAMS]: search does not reveal documents from other teams', async ({ page
await apiSignout({ page });
});
test('[PERSONAL]: search does not reveal team documents in personal account', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments();
await seedDocuments([
{
sender: teamMember2,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: null,
title: 'Personal Unique Document',
},
},
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Team Unique Document',
},
},
]);
await apiSignin({
page,
email: teamMember2.email,
redirectPath: '/documents',
});
await page.getByPlaceholder('Search documents...').fill('Unique');
await page.waitForURL(/query=Unique/);
await checkDocumentTabCount(page, 'All', 1);
await expect(page.getByRole('link', { name: 'Personal Unique Document' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Team Unique Document' })).not.toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({
page,
}) => {
const team = await seedTeam();
const { team, owner } = await seedTeam();
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
await seedDocuments([
{
sender: team.owner,
sender: owner,
teamId: team.id,
recipients: [memberUser],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document with Member Recipient',
},
@ -190,7 +193,7 @@ test('[TEAMS]: search respects recipient visibility regardless of team visibilit
});
test('[TEAMS]: search by recipient name respects visibility', async ({ page }) => {
const team = await seedTeam();
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
const memberUser = await seedTeamMember({
teamId: team.id,
@ -198,15 +201,15 @@ test('[TEAMS]: search by recipient name respects visibility', async ({ page }) =
name: 'Team Member',
});
const uniqueRecipient = await seedUser();
const { user: uniqueRecipient } = await seedUser();
await seedDocuments([
{
sender: team.owner,
sender: owner,
recipients: [uniqueRecipient],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document for Unique Recipient',
},

View File

@ -1,19 +1,23 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import {
seedBlankDocument,
seedDocuments,
seedTeamDocuments,
} from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { expectTextToBeVisible } from '../fixtures/generic';
test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments();
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
for (const user of [team.owner, teamMember2]) {
for (const user of [teamOwner, teamMember2]) {
await apiSignin({
page,
email: user.email,
@ -44,8 +48,12 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
});
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
const { team, teamMember2, teamMember4 } = await seedTeamDocuments();
const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
const { team, teamOwner, teamMember2, teamMember4 } = await seedTeamDocuments();
const {
team: team2,
teamOwner: team2Owner,
teamMember2: team2Member2,
} = await seedTeamDocuments();
const teamEmailMember = teamMember4;
@ -54,7 +62,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
teamId: team.id,
});
const testUser1 = await seedUser();
const { user: testUser1, team: testUser1Team } = await seedUser();
await seedDocuments([
// Documents sent from the team email account.
@ -62,52 +70,53 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
sender: teamEmailMember,
recipients: [testUser1],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
},
teamId: team.id,
documentOptions: {},
},
{
sender: teamEmailMember,
recipients: [testUser1],
type: DocumentStatus.PENDING,
documentOptions: {
teamId: team.id,
},
teamId: team.id,
documentOptions: {},
},
{
sender: teamMember4,
recipients: [testUser1],
type: DocumentStatus.DRAFT,
teamId: team.id,
},
// Documents sent to the team email account.
{
sender: testUser1,
recipients: [teamEmailMember],
type: DocumentStatus.COMPLETED,
teamId: testUser1Team.id,
},
{
sender: testUser1,
recipients: [teamEmailMember],
type: DocumentStatus.PENDING,
teamId: testUser1Team.id,
},
{
sender: testUser1,
recipients: [teamEmailMember],
type: DocumentStatus.DRAFT,
teamId: testUser1Team.id,
},
// Document sent to the team email account from another team.
{
sender: team2Member2,
recipients: [teamEmailMember],
type: DocumentStatus.PENDING,
documentOptions: {
teamId: team2.id,
},
teamId: team2.id,
documentOptions: {},
},
]);
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
for (const user of [teamOwner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
@ -138,7 +147,8 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
});
test('[TEAMS]: check team documents count with external team email', async ({ page }) => {
const { team, teamMember2 } = await seedTeamDocuments();
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
const teamEmail = `external-team-email-${team.id}@test.documenso.com`;
@ -148,7 +158,9 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
teamId: team.id,
});
const testUser1 = await seedUser();
const { user: testUser1, team: testUser1Team } = await seedUser({
isPersonalOrganisation: true,
});
await seedDocuments([
// Documents sent to the team email account.
@ -156,42 +168,39 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
sender: testUser1,
recipients: [teamEmail],
type: DocumentStatus.COMPLETED,
teamId: testUser1Team.id,
},
{
sender: testUser1,
recipients: [teamEmail],
type: DocumentStatus.PENDING,
teamId: testUser1Team.id,
},
{
sender: testUser1,
recipients: [teamEmail],
type: DocumentStatus.DRAFT,
teamId: testUser1Team.id,
},
// Document sent to the team email account from another team.
{
sender: team2Member2,
recipients: [teamEmail],
type: DocumentStatus.PENDING,
documentOptions: {
teamId: team2.id,
},
teamId: team2.id,
},
// Document sent to the team email account from an individual user.
{
sender: testUser1,
recipients: [teamEmail],
type: DocumentStatus.PENDING,
documentOptions: {
teamId: team2.id,
},
teamId: testUser1Team.id,
},
{
sender: testUser1,
recipients: [teamEmail],
type: DocumentStatus.DRAFT,
documentOptions: {
teamId: team2.id,
},
teamId: testUser1Team.id,
},
]);
@ -222,7 +231,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
});
test('[TEAMS]: resend pending team document', async ({ page }) => {
const { team, teamMember2: currentUser } = await seedTeamDocuments();
const { team, teamOwner, teamMember2: currentUser } = await seedTeamDocuments();
await apiSignin({
page,
@ -248,7 +257,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
const { team, teamOwner, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
@ -273,7 +282,7 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
for (const user of [teamOwner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
@ -292,7 +301,7 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
});
test('[TEAMS]: delete pending team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
const { team, teamOwner, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
@ -308,9 +317,9 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click({ force: true });
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click({ force: true });
await checkDocumentTabCount(page, 'Pending', 1);
@ -318,7 +327,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
for (const user of [teamOwner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
@ -337,7 +346,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
});
test('[TEAMS]: delete completed team document', async ({ page }) => {
const { team, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
const { team, teamOwner, teamMember2: teamEmailMember, teamMember3 } = await seedTeamDocuments();
await apiSignin({
page,
@ -353,9 +362,9 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click({ force: true });
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click({ force: true });
await checkDocumentTabCount(page, 'Completed', 0);
@ -363,7 +372,7 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
await apiSignout({ page });
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
for (const user of [team.owner, teamEmailMember]) {
for (const user of [teamOwner, teamEmailMember]) {
await apiSignin({
page,
email: user.email,
@ -382,7 +391,7 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
});
test('[TEAMS]: check document visibility based on team member role', async ({ page }) => {
const team = await seedTeam();
const { team, owner } = await seedTeam();
// Seed users with different roles
const adminUser = await seedTeamMember({
@ -400,46 +409,48 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
role: TeamMemberRole.MEMBER,
});
const outsideUser = await seedUser();
const { user: outsideUser, team: outsideUserTeam } = await seedUser({
isPersonalOrganisation: true,
});
// Seed documents with different visibility levels
await seedDocuments([
{
sender: team.owner,
sender: owner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Document Visible to Everyone',
},
},
{
sender: team.owner,
sender: owner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Document Visible to Manager and Above',
},
},
{
sender: team.owner,
sender: owner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Document Visible to Admin',
},
},
{
sender: team.owner,
sender: owner,
recipients: [outsideUser],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Document Visible to Admin with Recipient',
},
@ -470,11 +481,6 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
path: teamUrlRedirect,
expectedDocuments: ['Document Visible to Everyone'],
},
{
user: outsideUser,
path: '/documents',
expectedDocuments: ['Document Visible to Admin with Recipient'],
},
];
for (const testCase of testCases) {
@ -491,12 +497,20 @@ test('[TEAMS]: check document visibility based on team member role', async ({ pa
await apiSignout({ page });
}
await apiSignin({
page,
email: outsideUser.email,
redirectPath: '/inbox',
});
await expectTextToBeVisible(page, 'Document Visible to Admin with Recipient');
});
test('[TEAMS]: ensure document owner can see document regardless of visibility', async ({
page,
}) => {
const team = await seedTeam();
const { team, owner } = await seedTeam();
// Seed a member user
const memberUser = await seedTeamMember({
@ -510,8 +524,8 @@ test('[TEAMS]: ensure document owner can see document regardless of visibility',
sender: memberUser,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document with Member Document Owner',
},
@ -533,7 +547,7 @@ test('[TEAMS]: ensure document owner can see document regardless of visibility',
});
test('[TEAMS]: ensure recipient can see document regardless of visibility', async ({ page }) => {
const team = await seedTeam();
const { team, owner } = await seedTeam();
// Seed a member user
const memberUser = await seedTeamMember({
@ -544,11 +558,11 @@ test('[TEAMS]: ensure recipient can see document regardless of visibility', asyn
// Seed a document with ADMIN visibility but make the member user a recipient
await seedDocuments([
{
sender: team.owner,
sender: owner,
recipients: [memberUser],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Document with Member Recipient',
},
@ -570,7 +584,7 @@ test('[TEAMS]: ensure recipient can see document regardless of visibility', asyn
});
test('[TEAMS]: check that MEMBER role cannot see ADMIN-only documents', async ({ page }) => {
const team = await seedTeam();
const { team, owner } = await seedTeam();
// Seed a member user
const memberUser = await seedTeamMember({
@ -581,11 +595,11 @@ test('[TEAMS]: check that MEMBER role cannot see ADMIN-only documents', async ({
// Seed an ADMIN-only document
await seedDocuments([
{
sender: team.owner,
sender: owner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Only Document',
},
@ -609,7 +623,7 @@ test('[TEAMS]: check that MEMBER role cannot see ADMIN-only documents', async ({
test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only documents', async ({
page,
}) => {
const team = await seedTeam();
const { team, owner } = await seedTeam();
// Seed a member user
const memberUser = await seedTeamMember({
@ -620,11 +634,11 @@ test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only document
// Seed an ADMIN-only document
await seedDocuments([
{
sender: team.owner,
sender: owner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Manager and Above Only Document',
},
@ -646,7 +660,7 @@ test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only document
});
test('[TEAMS]: check that MANAGER role cannot see ADMIN-only documents', async ({ page }) => {
const team = await seedTeam();
const { team, owner } = await seedTeam();
// Seed a manager user
const managerUser = await seedTeamMember({
@ -657,11 +671,11 @@ test('[TEAMS]: check that MANAGER role cannot see ADMIN-only documents', async (
// Seed an ADMIN-only document
await seedDocuments([
{
sender: team.owner,
sender: owner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'ADMIN',
title: 'Admin Only Document',
},
@ -683,7 +697,7 @@ test('[TEAMS]: check that MANAGER role cannot see ADMIN-only documents', async (
});
test('[TEAMS]: check that ADMIN role can see MANAGER_AND_ABOVE documents', async ({ page }) => {
const team = await seedTeam();
const { team, owner } = await seedTeam();
// Seed an admin user
const adminUser = await seedTeamMember({
@ -694,11 +708,11 @@ test('[TEAMS]: check that ADMIN role can see MANAGER_AND_ABOVE documents', async
// Seed a MANAGER_AND_ABOVE document
await seedDocuments([
{
sender: team.owner,
sender: owner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: team.id,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Manager and Above Document',
},
@ -720,25 +734,16 @@ test('[TEAMS]: check that ADMIN role can see MANAGER_AND_ABOVE documents', async
});
test('[TEAMS]: check that ADMIN role can change document visibility', async ({ page }) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
},
},
});
const { team, owner } = await seedTeam();
const adminUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.ADMIN,
});
const document = await seedBlankDocument(adminUser, {
const document = await seedBlankDocument(adminUser, team.id, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
});
@ -763,25 +768,16 @@ test('[TEAMS]: check that ADMIN role can change document visibility', async ({ p
test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.EVERYONE,
},
},
},
});
const { team, owner } = await seedTeam();
const teamMember = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const document = await seedBlankDocument(teamMember, {
const document = await seedBlankDocument(teamMember, team.id, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
visibility: DocumentVisibility.EVERYONE,
},
});
@ -798,25 +794,16 @@ test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE docum
test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_ABOVE documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
},
},
});
const { team, owner } = await seedTeam();
const teamMember = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const document = await seedBlankDocument(teamMember, {
const document = await seedBlankDocument(teamMember, team.id, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
});
@ -833,25 +820,16 @@ test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_AB
test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.ADMIN,
},
},
},
});
const { team, owner } = await seedTeam();
const teamMember = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const document = await seedBlankDocument(teamMember, {
const document = await seedBlankDocument(teamMember, team.id, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
visibility: DocumentVisibility.ADMIN,
},
});
@ -868,25 +846,16 @@ test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN document
test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.ADMIN,
},
},
},
});
const { team, owner } = await seedTeam();
const teamManager = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const document = await seedBlankDocument(teamManager, {
const document = await seedBlankDocument(teamManager, team.id, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
visibility: DocumentVisibility.ADMIN,
},
});
@ -902,17 +871,25 @@ test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documen
test('[TEAMS]: users cannot see documents from other teams', async ({ page }) => {
// Seed two teams with documents
const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments();
const { team: teamB, teamMember2: teamBMember } = await seedTeamDocuments();
const {
team: teamA,
teamOwner: teamAOwner,
teamMember2: teamAMember,
} = await seedTeamDocuments();
const {
team: teamB,
teamOwner: teamBOwner,
teamMember2: teamBMember,
} = await seedTeamDocuments();
// Seed a document in team B
await seedDocuments([
{
sender: teamB.owner,
sender: teamBOwner,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: teamB.id,
documentOptions: {
teamId: teamB.id,
visibility: 'EVERYONE',
title: 'Team B Document',
},
@ -934,8 +911,8 @@ test('[TEAMS]: users cannot see documents from other teams', async ({ page }) =>
test('[TEAMS]: personal documents are not visible in team context', async ({ page }) => {
// Seed a team and a user with personal documents
const { team, teamMember2 } = await seedTeamDocuments();
const personalUser = await seedUser();
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
const { user: personalUser, team: personalUserTeam } = await seedUser();
// Seed a personal document for teamMember2
await seedDocuments([
@ -943,8 +920,8 @@ test('[TEAMS]: personal documents are not visible in team context', async ({ pag
sender: teamMember2,
recipients: [],
type: DocumentStatus.COMPLETED,
teamId: personalUserTeam.id,
documentOptions: {
teamId: null, // Indicates a personal document
visibility: 'EVERYONE',
title: 'Personal Document',
},
@ -965,34 +942,3 @@ test('[TEAMS]: personal documents are not visible in team context', async ({ pag
await apiSignout({ page });
});
test('[PERSONAL]: team documents are not visible in personal account', async ({ page }) => {
// Seed a team and a user with personal documents
const { team, teamMember2 } = await seedTeamDocuments();
// Seed a team document
await seedDocuments([
{
sender: teamMember2,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'EVERYONE',
title: 'Team Document',
},
},
]);
// Sign in as teamMember2 in the personal context
await apiSignin({
page,
email: teamMember2.email,
redirectPath: `/documents?status=COMPLETED`,
});
// Verify that the team document is not visible in the personal context
await expect(page.getByRole('link', { name: 'Team Document', exact: true })).not.toBeVisible();
await apiSignout({ page });
});

View File

@ -1,17 +1,17 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
import { seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[TEAMS]: send team email request', async ({ page }) => {
const team = await seedTeam();
const { user, team } = await seedUser();
await apiSignin({
page,
email: team.owner.email,
email: user.email,
password: 'password',
redirectPath: `/t/${team.url}/settings`,
});
@ -32,9 +32,7 @@ test('[TEAMS]: send team email request', async ({ page }) => {
});
test('[TEAMS]: accept team email request', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const { user, team } = await seedUser();
const teamEmailVerification = await seedTeamEmailVerification({
email: `team-email-verification--${team.url}@test.documenso.com`,
@ -46,14 +44,13 @@ test('[TEAMS]: accept team email request', async ({ page }) => {
});
test('[TEAMS]: delete team email', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
createTeamEmail: true,
const { user, team } = await seedUser({
setTeamEmailAsOwner: true,
});
await apiSignin({
page,
email: team.owner.email,
email: user.email,
redirectPath: `/t/${team.url}/settings`,
});
@ -66,23 +63,16 @@ test('[TEAMS]: delete team email', async ({ page }) => {
});
test('[TEAMS]: team email owner removes access', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
createTeamEmail: true,
});
const teamEmailOwner = await seedUser();
if (!team.teamEmail) {
throw new Error('Not possible');
}
const teamEmailOwner = await seedUser({
email: team.teamEmail.email,
const { user: secondUser } = await seedUser({
teamEmail: teamEmailOwner.user.email,
});
await apiSignin({
page,
email: teamEmailOwner.email,
redirectPath: `/settings/teams`,
email: teamEmailOwner.user.email,
redirectPath: `/settings/profile`,
});
await page.getByRole('button', { name: 'Revoke access' }).click();

View File

@ -1,55 +0,0 @@
import { expect, test } from '@playwright/test';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
test('[TEAMS]: update the default document visibility in the team global settings', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
// !: Brittle selector
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Admin' }).click();
await page.getByRole('button', { name: 'Update' }).first().click();
const toast = page.locator('li[role="status"][data-state="open"]').first();
await expect(toast).toBeVisible();
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
});
test('[TEAMS]: update the sender details in the team global settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/preferences`,
});
const checkbox = page.getByLabel('Send on Behalf of Team');
await checkbox.check();
await expect(checkbox).toBeChecked();
await page.getByRole('button', { name: 'Update' }).first().click();
const toast = page.locator('li[role="status"][data-state="open"]').first();
await expect(toast).toBeVisible();
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
await expect(checkbox).toBeChecked();
});

View File

@ -1,101 +0,0 @@
import { expect, test } from '@playwright/test';
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';
import { apiSignin } from '../fixtures/authentication';
test('[TEAMS]: update team member role', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings/members`,
});
const teamMemberToUpdate = team.members[1];
await page
.getByRole('row')
.filter({ hasText: teamMemberToUpdate.user.email })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: 'Update role' }).click();
await page.getByRole('combobox').click();
await page.getByLabel('Manager').click();
await page.getByRole('button', { name: 'Update' }).click();
await page.reload();
await expect(
page.getByRole('row').filter({ hasText: teamMemberToUpdate.user.email }).first(),
).toContainText('Manager');
});
test('[TEAMS]: accept team invitation without account', async ({ page }) => {
const team = await seedTeam();
const teamInvite = await seedTeamInvite({
email: `team-invite-test-${Date.now()}@test.documenso.com`,
teamId: team.id,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Team invitation');
});
test('[TEAMS]: accept team invitation with account', async ({ page }) => {
const team = await seedTeam();
const user = await seedUser();
const teamInvite = await seedTeamInvite({
email: user.email,
teamId: team.id,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
});
test('[TEAMS]: member can leave team', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMember = team.members[1];
await apiSignin({
page,
email: teamMember.user.email,
password: 'password',
redirectPath: `/settings/teams`,
});
await page.getByRole('button', { name: 'Leave' }).click();
await page.getByRole('button', { name: 'Leave' }).click();
await expect(page.getByRole('status').first()).toContainText(
'You have successfully left this team.',
);
});
test('[TEAMS]: owner cannot leave team', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/settings/teams`,
});
await expect(page.getByRole('button').getByText('Leave')).toBeDisabled();
});

View File

@ -3,27 +3,21 @@ import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import {
seedTeamDocumentWithMeta,
seedTeamDocuments,
seedTeamTemplateWithMeta,
} from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[TEAMS]: check that default team signature settings are all enabled', async ({ page }) => {
const { team } = await seedTeamDocuments();
const { user, team } = await seedUser();
await apiSignin({
page,
email: team.owner.email,
password: 'password',
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
});
// Verify that the default created team settings has all signatures enabled
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
const document = await seedTeamDocumentWithMeta(team);
// Create a document and check the settings
@ -46,12 +40,11 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
});
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
const { team } = await seedTeamDocuments();
const { user, team } = await seedUser();
await apiSignin({
page,
email: team.owner.email,
password: 'password',
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
});
@ -87,30 +80,38 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await page.getByRole('button', { name: 'Update' }).first().click();
// Wait for the update to complete
const toast = page.locator('li[role="status"][data-state="open"]').first();
await expect(toast).toBeVisible();
await expect(toast.getByText('Document preferences updated', { exact: true })).toBeVisible();
const document = await seedTeamDocumentWithMeta(team);
// Go to document and check that the signatured tabs are correct.
// Go to document and check that the signature tabs are correct.
await page.goto(`/sign/${document.recipients[0].token}`);
await page.getByTestId('signature-pad-dialog-button').click();
// Wait for signature dialog to fully load
await page.waitForSelector('[role="dialog"]');
// Check the tab values
for (const tab of allTabs) {
if (tabs.includes(tab)) {
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
} else {
await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
// await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
await expect(page.getByRole('tab', { name: tab })).toHaveCount(0);
}
}
}
});
test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
const { team } = await seedTeamDocuments();
const { user, team } = await seedUser();
await apiSignin({
page,
email: team.owner.email,
password: 'password',
email: user.email,
redirectPath: `/t/${team.url}/settings/preferences`,
});

View File

@ -1,63 +0,0 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamTransfer } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMember = team.members[1];
await apiSignin({
page,
email: team.owner.email,
password: 'password',
redirectPath: `/t/${team.url}/settings`,
});
await page.getByRole('button', { name: 'Transfer team' }).click();
await page.getByRole('combobox').click();
await page.getByLabel(teamMember.user.name ?? '').click();
await page.getByLabel('Confirm by typing transfer').click();
await page.getByLabel('Confirm by typing transfer').fill('transfer');
await page.getByRole('button', { name: 'Transfer' }).click();
await expect(page.locator('[id*="form-item-message"]').first()).toContainText(
`You must enter 'transfer ${team.name}' to proceed`,
);
await page.getByLabel('Confirm by typing transfer').click();
await page.getByLabel('Confirm by typing transfer').fill(`transfer ${team.name}`);
await page.getByRole('button', { name: 'Transfer' }).click();
await expect(page.getByRole('heading', { name: 'Team transfer in progress' })).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('status').first()).toContainText(
'The team transfer invitation has been successfully deleted.',
);
});
/**
* Current skipped until we disable billing during tests.
*/
test.skip('[TEAMS]: accept team transfer', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const newOwnerMember = team.members[1];
const teamTransferRequest = await seedTeamTransfer({
teamId: team.id,
newOwnerUserId: newOwnerMember.userId,
});
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/verify/transfer/${teamTransferRequest.token}`);
await expect(page.getByRole('heading')).toContainText('Team ownership transferred!');
});

View File

@ -1,138 +1,20 @@
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[TEMPLATE_FLOW] add action auth settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
});
test('[TEMPLATE_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
});
test('[TEMPLATE_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(teamMemberUser);
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/templates/${template.id}/edit`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholders' })).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
});
});
test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set title.
@ -140,7 +22,7 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
// Set access auth.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible.
@ -159,19 +41,13 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
});
test('[TEMPLATE_FLOW] add document visibility settings', async ({ page }) => {
const { owner, ...team } = await seedTeam({
createTeamMembers: 1,
});
const { user, team } = await seedUser();
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: owner.email,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
@ -196,24 +72,21 @@ test('[TEMPLATE_FLOW] add document visibility settings', async ({ page }) => {
});
test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
const team = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 2, // Create an additional member to test different roles
});
await prisma.teamMember.update({
where: {
id: team.members[1].id,
},
data: {
role: TeamMemberRole.MANAGER,
},
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const owner = team.owner;
const managerUser = team.members[1].user;
const memberUser = team.members[2].user;
const managerUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const template = await seedBlankTemplate(owner, {
const template = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: {
teamId: team.id,
},
@ -249,7 +122,7 @@ test('[TEMPLATE_FLOW] team member visibility permissions', async ({ page }) => {
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
// Create a new template with 'everyone' visibility
const everyoneTemplate = await seedBlankTemplate(owner, {
const everyoneTemplate = await seedBlankTemplate(owner, team.id, {
createTemplateOptions: {
teamId: team.id,
visibility: 'EVERYONE',

View File

@ -1,86 +1,85 @@
import { expect, test } from '@playwright/test';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test.describe('[EE_ONLY]', () => {
// eslint-disable-next-line turbo/no-undeclared-env-vars
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
// test.describe('[EE_ONLY]', () => {
// // eslint-disable-next-line turbo/no-undeclared-env-vars
// const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
// test.beforeEach(() => {
// test.skip(
// process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
// 'Billing required for this test',
// );
// });
test('[TEMPLATE_FLOW] add EE settings', async ({ page }) => {
const user = await seedUser();
// test('[TEMPLATE_FLOW] add EE settings', async ({ page }) => {
// const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
// await seedUserSubscription({
// userId: user.id,
// priceId: enterprisePriceId,
// });
const template = await seedBlankTemplate(user);
// const template = await seedBlankTemplate(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
});
// await apiSignin({
// page,
// email: user.email,
// redirectPath: `/templates/${template.id}/edit`,
// });
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// // Save the settings by going to the next step.
// await page.getByRole('button', { name: 'Continue' }).click();
// await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
// // Add 2 signers.
// await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
// await page.getByPlaceholder('Name').fill('Recipient 1');
// await page.getByRole('button', { name: 'Add Placeholder Recipient' }).click();
// await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
// await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').check();
// // Display advanced settings.
// await page.getByLabel('Show advanced settings').check();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// // Navigate to the next step and back.
// await page.getByRole('button', { name: 'Continue' }).click();
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// await page.getByRole('button', { name: 'Go Back' }).click();
// await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Expect that the advanced settings is unchecked, since no advanced settings were applied.
await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
// Add advanced settings for a single recipient.
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
// // Add advanced settings for a single recipient.
// await page.getByLabel('Show advanced settings').check();
// await page.getByRole('combobox').first().click();
// await page.getByLabel('Require passkey').click();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// // Navigate to the next step and back.
// await page.getByRole('button', { name: 'Continue' }).click();
// await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
// await page.getByRole('button', { name: 'Go Back' }).click();
// await expect(page.getByRole('heading', { name: 'Add Placeholder' })).toBeVisible();
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
});
});
// // Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// // settings were applied.
// await expect(page.getByLabel('Show advanced settings')).toBeHidden();
// });
// });
test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Save the settings by going to the next step.

View File

@ -5,15 +5,12 @@ import path from 'path';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const enterprisePriceId = '';
const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
/**
@ -26,21 +23,13 @@ const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
* If you update this test please update that test as well.
*/
test('[TEMPLATE]: should create a document from a template', async ({ page }) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title.
@ -48,16 +37,9 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
@ -82,25 +64,18 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Save template' }).click();
// Use template
await page.waitForURL('/templates');
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/documents/);
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
const documentId = Number(page.url().split('/').pop());
@ -119,10 +94,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
@ -142,35 +115,19 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
});
/**
* This is a direct copy paste of the above test but for teams.
*/
test('[TEMPLATE]: should create a team document from a team template', async ({ page }) => {
const { owner, ...team } = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
const isBillingEnabled =
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' && enterprisePriceId;
await seedUserSubscription({
userId: owner.id,
priceId: enterprisePriceId,
});
const template = await seedBlankTemplate(owner, team.id);
await apiSignin({
page,
@ -183,16 +140,9 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
// Set template document access.
await page.getByTestId('documentAccessSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await page.getByRole('option').filter({ hasText: 'Require account' }).click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Set EE action auth.
if (isBillingEnabled) {
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require passkey').getByText('Require passkey').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
}
// Set email options.
await page.getByRole('button', { name: 'Email Options' }).click();
await page.getByLabel('Subject (Optional)').fill('SUBJECT');
@ -217,13 +167,6 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
await page.getByPlaceholder('Email').nth(1).fill('recipient2@documenso.com');
await page.getByPlaceholder('Name').nth(1).fill('Recipient 2');
// Apply require passkey for Recipient 1.
if (isBillingEnabled) {
await page.getByLabel('Show advanced settings').check();
await page.getByRole('combobox').first().click();
await page.getByLabel('Require passkey').click();
}
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
@ -235,7 +178,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct values.
await page.waitForURL(/documents/);
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
const documentId = Number(page.url().split('/').pop());
@ -256,10 +199,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
});
expect(document.title).toEqual('TEMPLATE_TITLE');
expect(documentAuth.documentAuthOption.globalAccessAuth).toEqual('ACCOUNT');
expect(documentAuth.documentAuthOption.globalActionAuth).toEqual(
isBillingEnabled ? 'PASSKEY' : null,
);
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
expect(document.documentMeta?.message).toEqual('MESSAGE');
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
@ -279,12 +219,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
recipientAuth: recipientTwo.authOptions,
});
if (isBillingEnabled) {
expect(recipientOneAuth.derivedRecipientActionAuth).toEqual('PASSKEY');
}
expect(recipientOneAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toEqual('ACCOUNT');
expect(recipientOneAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
expect(recipientTwoAuth.derivedRecipientAccessAuth).toContain('ACCOUNT');
});
/**
@ -294,8 +230,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
test('[TEMPLATE]: should create a document from a template with custom document', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
// Create a temporary PDF file for upload
@ -304,7 +240,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title
@ -323,7 +259,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
await page.getByRole('button', { name: 'Save template' }).click();
// Use template with custom document
await page.waitForURL('/templates');
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
// Enable custom document upload and upload file
@ -348,7 +284,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
const documentId = Number(page.url().split('/').pop());
@ -361,10 +297,22 @@ test('[TEMPLATE]: should create a document from a template with custom document'
},
});
const expectedDocumentDataType =
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3'
? DocumentDataType.S3_PATH
: DocumentDataType.BYTES_64;
expect(document.title).toEqual('TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
expect(document.documentData.type).toEqual(expectedDocumentDataType);
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
} else {
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
expect(document.documentData.data).toBeTruthy();
expect(document.documentData.initialData).toBeTruthy();
}
});
/**
@ -374,15 +322,11 @@ test('[TEMPLATE]: should create a document from a template with custom document'
test('[TEMPLATE]: should create a team document from a template with custom document', async ({
page,
}) => {
const { owner, ...team } = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
const template = await seedBlankTemplate(owner, team.id);
const pdfContent = fs.readFileSync(EXAMPLE_PDF_PATH).toString('base64');
@ -433,7 +377,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the custom document data
await page.waitForURL(/documents/);
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
const documentId = Number(page.url().split('/').pop());
@ -446,11 +390,23 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
},
});
const expectedDocumentDataType =
process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT === 's3'
? DocumentDataType.S3_PATH
: DocumentDataType.BYTES_64;
expect(document.teamId).toEqual(team.id);
expect(document.title).toEqual('TEAM_TEMPLATE_WITH_CUSTOM_DOC');
expect(document.documentData.type).toEqual(DocumentDataType.BYTES_64);
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
expect(document.documentData.type).toEqual(expectedDocumentDataType);
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
expect(document.documentData.data).toEqual(pdfContent);
expect(document.documentData.initialData).toEqual(pdfContent);
} else {
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
expect(document.documentData.data).toBeTruthy();
expect(document.documentData.initialData).toBeTruthy();
}
});
/**
@ -460,13 +416,13 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
test('[TEMPLATE]: should create a document from a template using template document when custom document is not enabled', async ({
page,
}) => {
const user = await seedUser();
const template = await seedBlankTemplate(user);
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
await apiSignin({
page,
email: user.email,
redirectPath: `/templates/${template.id}/edit`,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
// Set template title
@ -485,7 +441,7 @@ test('[TEMPLATE]: should create a document from a template using template docume
await page.getByRole('button', { name: 'Save template' }).click();
// Use template without custom document
await page.waitForURL('/templates');
await page.waitForURL(`/t/${team.url}/templates`);
await page.getByRole('button', { name: 'Use Template' }).click();
// Verify custom document upload is not checked by default
@ -495,7 +451,7 @@ test('[TEMPLATE]: should create a document from a template using template docume
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the template's document data
await page.waitForURL(/documents/);
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
const documentId = Number(page.url().split('/').pop());
@ -528,15 +484,11 @@ test('[TEMPLATE]: should create a document from a template using template docume
test('[TEMPLATE]: should persist document visibility when creating from template', async ({
page,
}) => {
const { owner, ...team } = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 2,
});
const template = await seedBlankTemplate(owner, {
createTemplateOptions: {
teamId: team.id,
},
});
const template = await seedBlankTemplate(owner, team.id);
await apiSignin({
page,
@ -565,17 +517,11 @@ test('[TEMPLATE]: should persist document visibility when creating from template
await page.getByRole('button', { name: 'Save template' }).click();
// Test creating document as team manager
await prisma.teamMember.update({
where: {
id: team.members[1].id,
},
data: {
role: TeamMemberRole.MANAGER,
},
const managerUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const managerUser = team.members[1].user;
await apiSignin({
page,
email: managerUser.email,
@ -586,7 +532,7 @@ test('[TEMPLATE]: should persist document visibility when creating from template
await page.getByRole('button', { name: 'Create as draft' }).click();
// Review that the document was created with the correct visibility
await page.waitForURL(/documents/);
await page.waitForURL(new RegExp(`/t/${team.url}/documents/\\d+`));
const documentId = Number(page.url().split('/').pop());
@ -601,7 +547,11 @@ test('[TEMPLATE]: should persist document visibility when creating from template
expect(document.teamId).toEqual(team.id);
// Test that regular member cannot create document from restricted template
const memberUser = team.members[2].user;
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
await apiSignin({
page,
email: memberUser.email,

View File

@ -2,10 +2,6 @@ import { expect, test } from '@playwright/test';
import { customAlphabet } from 'nanoid';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { seedTeam } from '@documenso/prisma/seed/teams';
@ -13,29 +9,18 @@ import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templat
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
// Duped from `packages/lib/utils/teams.ts` due to errors when importing that file.
const formatDocumentsPath = (teamUrl?: string) =>
teamUrl ? `/t/${teamUrl}/documents` : '/documents';
const formatTemplatesPath = (teamUrl?: string) =>
teamUrl ? `/t/${teamUrl}/templates` : '/templates';
const formatDocumentsPath = (teamUrl: string) => `/t/${teamUrl}/documents`;
const formatTemplatesPath = (teamUrl: string) => `/t/${teamUrl}/templates`;
const nanoid = customAlphabet('1234567890abcdef', 10);
test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) => {
const team = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
// Should only be visible to the owner in personal templates.
const personalTemplate = await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
const teamTemplate = await seedTemplate({
title: 'Team template 1',
@ -46,49 +31,35 @@ test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) =>
await apiSignin({
page,
email: owner.email,
redirectPath: '/templates',
redirectPath: `/t/${team.url}/templates`,
});
const urls = [
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates/${teamTemplate.id}`,
`${NEXT_PUBLIC_WEBAPP_URL()}/templates/${personalTemplate.id}`,
];
const url = `${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates/${teamTemplate.id}`;
// Run test for personal and team templates.
for (const url of urls) {
// Owner should see list of templates with no direct link badge.
await page.goto(url);
await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(1);
// Owner should see list of templates with no direct link badge.
await page.goto(url);
await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(1);
// Create direct link.
await page.getByRole('button', { name: 'Create Direct Link' }).click();
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
await page.getByRole('button', { name: 'Create one automatically' }).click();
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
// Create direct link.
await page.getByRole('button', { name: 'Create Direct Link' }).click();
await page.getByRole('button', { name: 'Enable direct link signing' }).click();
await page.getByRole('button', { name: 'Create one automatically' }).click();
await expect(page.getByRole('heading', { name: 'Direct Link Signing' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByTestId('btn-dialog-close').click();
await page.waitForTimeout(1000);
await page.getByTestId('btn-dialog-close').click();
// Expect badge to appear.
await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(2);
}
// Expect badge to appear.
await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(2);
});
test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
const team = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
// Should only be visible to the owner in personal templates.
const personalDirectTemplate = await seedDirectTemplate({
title: 'Personal direct template link',
userId: owner.id,
});
// Should be visible to team members.
const teamDirectTemplate = await seedDirectTemplate({
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
@ -99,41 +70,30 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
email: owner.email,
});
// Run test for personal and team templates.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Navigate to template settings and disable access.
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();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Direct link signing has been').first()).toBeVisible();
// Navigate to template settings and disable access.
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();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Direct link signing has been').first()).toBeVisible();
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('404 not found')).toBeVisible();
}
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('404 not found')).toBeVisible();
});
test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
const team = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
// Should only be visible to the owner in personal templates.
const personalDirectTemplate = await seedDirectTemplate({
title: 'Personal direct template link',
userId: owner.id,
});
// Should be visible to team members.
const teamDirectTemplate = await seedDirectTemplate({
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
@ -144,36 +104,34 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
email: owner.email,
});
// Run test for personal and team templates.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Navigate to template settings and delete the access.
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();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Direct template link deleted').first()).toBeVisible();
// Navigate to template settings and delete the access.
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();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Direct template link deleted').first()).toBeVisible();
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('404 not found')).toBeVisible();
}
// Check that the direct template link is no longer accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('404 not found')).toBeVisible();
});
test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
const directTemplateWithAuth = await seedDirectTemplate({
title: 'Personal direct template link',
userId: user.id,
teamId: team.id,
createTemplateOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: 'ACCOUNT',
globalActionAuth: null,
globalAccessAuth: ['ACCOUNT'],
globalActionAuth: [],
}),
},
});
@ -198,136 +156,29 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) =>
});
test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => {
const team = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
// Should only be visible to the owner in personal templates.
const personalDirectTemplate = await seedDirectTemplate({
title: 'Personal direct template link',
userId: owner.id,
});
// Should be visible to team members.
const teamDirectTemplate = await seedDirectTemplate({
const template = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
});
// Run test for personal and team templates.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
}
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
await apiSignin({
page,
email: owner.email,
});
// Check that the owner has the documents.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(template.team?.url)}`);
await expect(async () => {
// Check that the document is in the 'All' tab.
await checkDocumentTabCount(page, 'Completed', 1);
}).toPass();
}
});
test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const secondRecipient = await seedUser();
const createTemplateOptions = {
recipients: {
createMany: {
data: [
{
email: DIRECT_TEMPLATE_RECIPIENT_EMAIL,
name: DIRECT_TEMPLATE_RECIPIENT_NAME,
token: nanoid(),
},
{
email: secondRecipient.email,
token: nanoid(),
},
],
},
},
};
// Should only be visible to the owner in personal templates.
const personalDirectTemplate = await seedDirectTemplate({
title: 'Personal direct template link',
userId: owner.id,
createTemplateOptions,
});
// Should be visible to team members.
const teamDirectTemplate = await seedDirectTemplate({
title: 'Team direct template link 1',
userId: owner.id,
teamId: team.id,
createTemplateOptions,
});
// Run test for personal and team templates.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
// Check that the direct template link is accessible.
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(1000);
await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByText('Waiting for others to sign')).toBeVisible();
}
await apiSignin({
page,
email: owner.email,
});
// Check that the owner has the documents.
for (const template of [personalDirectTemplate, teamDirectTemplate]) {
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);
await checkDocumentTabCount(page, 'Pending', 1);
}
// Check that the second recipient has the 2 pending documents.
await apiSignin({
page,
email: secondRecipient.email,
});
await page.goto('/documents');
await checkDocumentTabCount(page, 'All', 2);
await checkDocumentTabCount(page, 'Inbox', 2);
// Add a longer waiting period to ensure document status is updated
await page.waitForTimeout(3000);
});

View File

@ -1,23 +1,19 @@
import { expect, test } from '@playwright/test';
import { TeamMemberRole } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { apiSignin } from '../fixtures/authentication';
test('[TEMPLATES]: view templates', async ({ page }) => {
const team = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
const teamMemberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
// Should be visible to team members.
@ -37,29 +33,21 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
await apiSignin({
page,
email: owner.email,
redirectPath: '/templates',
redirectPath: `/t/${team.url}/templates`,
});
// Only should only see their personal template.
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 }) => {
const team = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
const teamMemberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
// Should be visible to team members.
@ -79,18 +67,9 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
await apiSignin({
page,
email: owner.email,
redirectPath: '/templates',
redirectPath: `/t/${team.url}/templates`,
});
// Owner should be able to delete their personal template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
// Team member should be able to delete all templates.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
for (const template of ['Team template 1', 'Team template 2']) {
await page
.getByRole('row', { name: template })
@ -108,17 +87,13 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
});
test('[TEMPLATES]: duplicate template', async ({ page }) => {
const team = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
const teamMemberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
// Should be visible to team members.
@ -131,18 +106,9 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
await apiSignin({
page,
email: owner.email,
redirectPath: '/templates',
redirectPath: `/t/${team.url}/templates`,
});
// Duplicate personal 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.getByTestId('data-table-count')).toContainText('Showing 2 results');
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();
@ -152,17 +118,13 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
});
test('[TEMPLATES]: use template', async ({ page }) => {
const team = await seedTeam({
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
const teamMemberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
// Should be visible to team members.
@ -175,27 +137,9 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await apiSignin({
page,
email: owner.email,
redirectPath: '/templates',
redirectPath: `/t/${team.url}/templates`,
});
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/templates`);
await page.waitForTimeout(1000);
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();

View File

@ -1,6 +1,6 @@
import { type Page, expect, test } from '@playwright/test';
import { alphaid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
extractUserVerificationToken,
seedTestEmail,
@ -23,17 +23,29 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
await signSignaturePad(page);
await page.getByRole('button', { name: 'Next', exact: true }).click();
await page.getByLabel('Public profile username').fill(alphaid(10));
await page.getByLabel('Public profile username').blur();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Complete', exact: true }).click();
await page.waitForURL('/unverified-account');
// Wait to ensure token is created in the database
await page.waitForTimeout(2000);
const { token } = await extractUserVerificationToken(email);
const team = await prisma.team.findFirstOrThrow({
where: {
organisation: {
members: {
some: {
user: {
email,
},
},
},
},
},
});
await page.goto(`/verify-email/${token}`);
await expect(page.getByRole('heading')).toContainText('Email Confirmed!');
@ -41,19 +53,19 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
// We now automatically redirect to the home page
await page.getByRole('link', { name: 'Continue' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
// Expect to be redirected to their only team.
await page.waitForURL(`/t/${team.url}/documents`);
await expect(page).toHaveURL(`/t/${team.url}/documents`);
});
test('[USER] can sign in using email and password', async ({ page }: { page: Page }) => {
const user = await seedUser();
const { user, team } = await seedUser();
await page.goto('/signin');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Password', { exact: true }).fill('password');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
await page.waitForURL(`/t/${team.url}/documents`);
await expect(page).toHaveURL(`/t/${team.url}/documents`);
});

View File

@ -0,0 +1,82 @@
import { type Page, expect, test } from '@playwright/test';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { expectTextToBeVisible } from '../fixtures/generic';
test('[USER] revoke sessions', async ({ page }: { page: Page }) => {
const { user, team } = await seedUser();
await apiSignin({
page,
email: user.email,
password: 'password',
redirectPath: '/settings/security/sessions',
});
// Expect 2 rows length (header + 1)
await expect(page.getByRole('row')).toHaveCount(2);
// Clear cookies
await page.context().clearCookies();
await apiSignin({
page,
email: user.email,
password: 'password',
redirectPath: '/settings/security/sessions',
});
await page.context().clearCookies();
await apiSignin({
page,
email: user.email,
password: 'password',
redirectPath: '/settings/security/sessions',
});
// Expect 4 (3 sessions + 1 header) rows length
await expect(page.getByRole('row')).toHaveCount(4);
// Revoke all sessions
await page.getByRole('button', { name: 'Revoke all sessions' }).click();
await page.getByRole('button', { name: 'Revoke all sessions' }).click();
await expectTextToBeVisible(page, 'Sessions have been revoked');
// Expect (1 sessions + 1 header) rows length
await expect(page.getByRole('row')).toHaveCount(2);
await page.context().clearCookies();
await apiSignin({
page,
email: user.email,
password: 'password',
redirectPath: '/settings/security/sessions',
});
// Find table row which does not have text 'Current' and click the button called Revoke within the row.
await page
.getByRole('row')
.filter({ hasNotText: 'Current' })
.nth(1)
.getByRole('button', { name: 'Revoke' })
.click();
await expectTextToBeVisible(page, 'Session revoked');
// Expect (1 sessions + 1 header) rows length
await expect(page.getByRole('row')).toHaveCount(2);
// Revoke own session.
await page
.getByRole('row')
.filter({ hasText: 'Current' })
.first()
.getByRole('button', { name: 'Revoke' })
.click();
await expect(page).toHaveURL('/signin');
});

View File

@ -7,7 +7,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
test('[USER] delete account', async ({ page }) => {
const user = await seedUser();
const { user } = await seedUser();
await apiSignin({ page, email: user.email, redirectPath: '/settings' });

View File

@ -11,15 +11,20 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const user = await seedUser({
const { user } = await seedUser({
password: oldPassword,
});
await page.goto('http://localhost:3000/signin');
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await page.getByRole('textbox', { name: 'Email' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
await expect(page.getByRole('button', { name: 'Reset Password' })).toBeEnabled();
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Reset email sent');
await expect(page.locator('body')).toContainText('Reset email sent', { timeout: 10000 });
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
where: {
@ -33,16 +38,26 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
// Assert that password cannot be same as old password.
await page.getByRole('textbox', { name: 'Password', exact: true }).fill(oldPassword);
await page.getByRole('textbox', { name: 'Repeat Password' }).fill(oldPassword);
await page.getByLabel('Password', { exact: true }).fill(oldPassword);
await page.getByLabel('Repeat Password').fill(oldPassword);
// Ensure both fields are filled before clicking
await expect(page.getByLabel('Password', { exact: true })).toHaveValue(oldPassword);
await expect(page.getByLabel('Repeat Password')).toHaveValue(oldPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText(
'Your new password cannot be the same as your old password.',
);
// Assert password reset.
await page.getByRole('textbox', { name: 'Password', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Repeat Password' }).fill(newPassword);
await page.getByLabel('Password', { exact: true }).fill(newPassword);
await page.getByLabel('Repeat Password').fill(newPassword);
// Ensure both fields are filled before clicking
await expect(page.getByLabel('Password', { exact: true })).toHaveValue(newPassword);
await expect(page.getByLabel('Repeat Password')).toHaveValue(newPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
@ -51,17 +66,18 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
page,
email: user.email,
password: newPassword,
redirectPath: '/settings/profile',
});
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
await page.waitForURL('/settings/profile');
await expect(page).toHaveURL('/settings/profile');
});
test('[USER] can reset password via user settings', async ({ page }: { page: Page }) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const user = await seedUser({
const { user } = await seedUser({
password: oldPassword,
});
@ -72,9 +88,9 @@ test('[USER] can reset password via user settings', async ({ page }: { page: Pag
redirectPath: '/settings/security',
});
await page.getByRole('textbox', { name: 'Current password' }).fill(oldPassword);
await page.getByRole('textbox', { name: 'New password' }).fill(newPassword);
await page.getByRole('textbox', { name: 'Repeat password' }).fill(newPassword);
await page.getByLabel('Current password').fill(oldPassword);
await page.getByLabel('New password').fill(newPassword);
await page.getByLabel('Repeat password').fill(newPassword);
await page.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('Password updated');
@ -87,8 +103,9 @@ test('[USER] can reset password via user settings', async ({ page }: { page: Pag
page,
email: user.email,
password: newPassword,
redirectPath: '/settings/profile',
});
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
await page.waitForURL('/settings/profile');
await expect(page).toHaveURL('/settings/profile');
});

View File

@ -7,7 +7,7 @@ import { apiSignin } from '../fixtures/authentication';
import { signSignaturePad } from '../fixtures/signature';
test('[USER] update full name', async ({ page }) => {
const user = await seedUser();
const { user } = await seedUser();
await apiSignin({ page, email: user.email, redirectPath: '/settings/profile' });

View File

@ -24,19 +24,23 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 4 : 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
reporter: [['html'], ['list']],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
trace: 'on',
video: 'retain-on-failure',
/* Add explicit timeouts for actions */
actionTimeout: 15_000,
navigationTimeout: 30_000,
},
timeout: 30_000,
timeout: 60_000,
/* Configure projects for major browsers */
projects: [

Binary file not shown.

Binary file not shown.

View File

@ -7,6 +7,7 @@ import { AppError } from '@documenso/lib/errors/app-error';
import type { AuthAppType } from '../server';
import type { SessionValidationResult } from '../server/lib/session/session';
import type { ActiveSession } from '../server/lib/utils/get-session';
import { handleSignInRedirect } from '../server/lib/utils/redirect';
import type {
TDisableTwoFactorRequestSchema,
@ -47,6 +48,26 @@ export class AuthClient {
window.location.href = redirectPath ?? this.signOutredirectPath;
}
public async signOutAllSessions() {
await this.client['signout-all'].$post();
}
public async signOutSession({
sessionId,
redirectPath,
}: {
sessionId: string;
redirectPath?: string;
}) {
await this.client['signout-session'].$post({
json: { sessionId },
});
if (redirectPath) {
window.location.href = redirectPath;
}
}
public async getSession() {
const response = await this.client['session-json'].$get();
@ -57,6 +78,16 @@ export class AuthClient {
return superjson.deserialize<SessionValidationResult>(result);
}
public async getSessions() {
const response = await this.client['sessions'].$get();
await this.handleError(response);
const result = await response.json();
return superjson.deserialize<{ sessions: ActiveSession[] }>(result);
}
private async handleError<T>(response: ClientResponse<T>): Promise<void> {
if (!response.ok) {
const error = await response.json();

View File

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

View File

@ -2,6 +2,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { type Session, type User, UserSecurityAuditLogType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
@ -22,8 +23,6 @@ export type SessionUser = Pick<
| 'twoFactorEnabled'
| 'roles'
| 'signature'
| 'url'
| 'customerId'
>;
export type SessionValidationResult =
@ -99,8 +98,6 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
twoFactorEnabled: true,
roles: true,
signature: true,
url: true,
customerId: true,
},
},
},
@ -133,18 +130,46 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
return { session, user, isAuthenticated: true };
};
export const invalidateSession = async (
sessionId: string,
metadata: RequestMetadata,
): Promise<void> => {
const session = await prisma.session.delete({ where: { id: sessionId } });
type InvalidateSessionsOptions = {
userId: number;
sessionIds: string[];
metadata: RequestMetadata;
isRevoke?: boolean;
};
await prisma.userSecurityAuditLog.create({
data: {
userId: session.userId,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
type: UserSecurityAuditLogType.SIGN_OUT,
},
export const invalidateSessions = async ({
userId,
sessionIds,
metadata,
isRevoke,
}: InvalidateSessionsOptions): Promise<void> => {
if (sessionIds.length === 0) {
return;
}
await prisma.$transaction(async (tx) => {
const { count } = await tx.session.deleteMany({
where: {
userId,
id: { in: sessionIds },
},
});
if (count !== sessionIds.length) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'One or more sessions are not valid.',
});
}
await tx.userSecurityAuditLog.createMany({
data: sessionIds.map(() => ({
userId,
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
type: isRevoke
? UserSecurityAuditLogType.SESSION_REVOKED
: UserSecurityAuditLogType.SIGN_OUT,
})),
});
});
};

View File

@ -1,6 +1,8 @@
import type { Session } from '@prisma/client';
import type { Context } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { AuthenticationErrorCode } from '../errors/error-codes';
import type { SessionValidationResult } from '../session/session';
@ -37,6 +39,33 @@ export const getOptionalSession = async (
return await validateSessionToken(sessionId);
};
export type ActiveSession = Omit<Session, 'sessionToken'>;
export const getActiveSessions = async (c: Context | Request): Promise<ActiveSession[]> => {
const { user } = await getSession(c);
return await prisma.session.findMany({
where: {
userId: user.id,
expiresAt: {
gt: new Date(),
},
},
orderBy: {
updatedAt: 'desc',
},
select: {
id: true,
userId: true,
expiresAt: true,
updatedAt: true,
createdAt: true,
ipAddress: true,
userAgent: true,
},
});
};
/**
* Todo: (RR7) Rethink, this is pretty sketchy.
*/

View File

@ -2,7 +2,6 @@ import { UserSecurityAuditLogType } from '@prisma/client';
import { OAuth2Client, decodeIdToken } from 'arctic';
import type { Context } from 'hono';
import { deleteCookie } from 'hono/cookie';
import { nanoid } from 'nanoid';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
@ -54,7 +53,7 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
let [redirectState, redirectPath] = storedRedirectPath.split(' ');
if (redirectState !== storedState || !redirectPath) {
redirectPath = '/documents';
redirectPath = '/';
}
const tokens = await oAuthClient.validateAuthorizationCode(
@ -164,7 +163,6 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
email: email,
name: name,
emailVerified: new Date(),
url: nanoid(17),
},
});

View File

@ -11,17 +11,17 @@ export const handleRequestRedirect = (redirectUrl?: string) => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/documents';
window.location.href = '/';
} else {
window.location.href = redirectUrl;
}
};
export const handleSignInRedirect = (redirectUrl: string = '/documents') => {
export const handleSignInRedirect = (redirectUrl: string = '/') => {
const url = new URL(redirectUrl, NEXT_PUBLIC_WEBAPP_URL());
if (url.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
window.location.href = '/documents';
window.location.href = '/';
} else {
window.location.href = redirectUrl;
}

View File

@ -5,7 +5,6 @@ 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';
@ -148,15 +147,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
});
}
const { name, email, password, signature, url } = c.req.valid('json');
const { name, email, password, signature } = 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 });
const user = await createUser({ name, email, password, signature }).catch((err) => {
console.error(err);
throw err;
});
await jobsClient.triggerJob({
name: 'send.signup.confirmation.email',

View File

@ -114,7 +114,7 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
return c.json(
{
url: '/documents',
url: '/',
},
200,
);

View File

@ -2,7 +2,7 @@ import { Hono } from 'hono';
import superjson from 'superjson';
import type { SessionValidationResult } from '../lib/session/session';
import { getOptionalSession } from '../lib/utils/get-session';
import { getActiveSessions, getOptionalSession } from '../lib/utils/get-session';
export const sessionRoute = new Hono()
.get('/session', async (c) => {
@ -10,6 +10,11 @@ export const sessionRoute = new Hono()
return c.json(session);
})
.get('/sessions', async (c) => {
const sessions = await getActiveSessions(c);
return c.json(superjson.serialize({ sessions }));
})
.get('/session-json', async (c) => {
const session: SessionValidationResult = await getOptionalSession(c);

View File

@ -1,27 +1,114 @@
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { invalidateSession, validateSessionToken } from '../lib/session/session';
import { prisma } from '@documenso/prisma';
import { invalidateSessions, 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);
const ZSignoutSessionSchema = z.object({
sessionId: z.string().trim().min(1),
});
export const signOutRoute = new Hono<HonoAuthContext>()
.post('/signout', async (c) => {
const metadata = c.get('requestMetadata');
const sessionToken = await getSessionCookie(c);
if (!sessionToken) {
return new Response('No session found', { status: 401 });
}
const { session } = await validateSessionToken(sessionToken);
if (!session) {
deleteSessionCookie(c);
return new Response('No session found', { status: 401 });
}
await invalidateSessions({
userId: session.userId,
sessionIds: [session.id],
metadata,
isRevoke: false,
});
deleteSessionCookie(c);
return c.status(200);
})
.post('/signout-all', async (c) => {
const metadata = c.get('requestMetadata');
const sessionToken = await getSessionCookie(c);
if (!sessionToken) {
return new Response('No session found', { status: 401 });
}
const { session } = await validateSessionToken(sessionToken);
if (!session) {
deleteSessionCookie(c);
return new Response('No session found', { status: 401 });
}
const userId = session.userId;
const userSessionIds = await prisma.session
.findMany({
where: {
userId,
id: {
not: session.id,
},
},
select: {
id: true,
},
})
.then((sessions) => sessions.map((session) => session.id));
await invalidateSessions({
userId,
sessionIds: userSessionIds,
metadata,
isRevoke: true,
});
return c.status(200);
})
.post('/signout-session', sValidator('json', ZSignoutSessionSchema), async (c) => {
const metadata = c.get('requestMetadata');
const { sessionId: sessionIdToRevoke } = c.req.valid('json');
const sessionToken = await getSessionCookie(c);
if (!sessionToken) {
return new Response('No session found', { status: 401 });
}
const { session } = await validateSessionToken(sessionToken);
if (!session) {
deleteSessionCookie(c);
return new Response('No session found', { status: 401 });
}
await invalidateSessions({
userId: session.userId,
sessionIds: [sessionIdToRevoke],
metadata,
isRevoke: true,
});
if (session.id === sessionIdToRevoke) {
deleteSessionCookie(c);
}
return c.status(200);
});

View File

@ -37,15 +37,6 @@ export const ZSignUpSchema = z.object({
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>;

View File

@ -6,10 +6,10 @@ import { ZLimitsResponseSchema } from './schema';
export type GetLimitsOptions = {
headers?: Record<string, string>;
teamId?: number | null;
teamId: number;
};
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions) => {
const requestHeaders = headers ?? {};
const url = new URL('/api/limits', NEXT_PUBLIC_WEBAPP_URL());

View File

@ -6,7 +6,13 @@ export const FREE_PLAN_LIMITS: TLimitsSchema = {
directTemplates: 3,
};
export const TEAM_PLAN_LIMITS: TLimitsSchema = {
export const INACTIVE_PLAN_LIMITS: TLimitsSchema = {
documents: 0,
recipients: 0,
directTemplates: 0,
};
export const PAID_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
directTemplates: Infinity,

View File

@ -17,11 +17,11 @@ export const limitsHandler = async (req: Request) => {
teamId = parseInt(rawTeamId, 10);
}
if (!teamId && rawTeamId) {
if (!teamId) {
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
}
const limits = await getServerLimits({ email: user.email, teamId });
const limits = await getServerLimits({ userId: user.id, teamId });
return Response.json(limits, {
status: 200,

View File

@ -22,7 +22,7 @@ export const useLimits = () => {
export type LimitsProviderProps = {
initialValue?: TLimitsResponseSchema;
teamId?: number;
teamId: number;
children?: React.ReactNode;
};

View File

@ -2,21 +2,25 @@ import { DocumentSource, SubscriptionStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import {
FREE_PLAN_LIMITS,
INACTIVE_PLAN_LIMITS,
PAID_PLAN_LIMITS,
SELFHOSTED_PLAN_LIMITS,
} from './constants';
import { ERROR_CODES } from './errors';
import type { TLimitsResponseSchema } from './schema';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
email: string;
teamId?: number | null;
userId: number;
teamId: number;
};
export const getServerLimits = async ({
email,
userId,
teamId,
}: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => {
if (!IS_BILLING_ENABLED()) {
@ -26,68 +30,65 @@ export const getServerLimits = async ({
};
}
if (!email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
return teamId ? handleTeamLimits({ email, teamId }) : handleUserLimits({ email });
};
type HandleUserLimitsOptions = {
email: string;
};
const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
const user = await prisma.user.findFirst({
const organisation = await prisma.organisation.findFirst({
where: {
email,
teams: {
some: {
id: teamId,
},
},
members: {
some: {
userId,
},
},
},
include: {
subscriptions: true,
subscription: true,
organisationClaim: true,
},
});
if (!user) {
if (!organisation) {
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
}
let quota = structuredClone(FREE_PLAN_LIMITS);
let remaining = structuredClone(FREE_PLAN_LIMITS);
const quota = structuredClone(FREE_PLAN_LIMITS);
const remaining = structuredClone(FREE_PLAN_LIMITS);
const activeSubscriptions = user.subscriptions.filter(
({ status }) => status === SubscriptionStatus.ACTIVE,
);
const subscription = organisation.subscription;
if (activeSubscriptions.length > 0) {
const documentPlanPrices = await getDocumentRelatedPrices();
// Bypass all limits even if plan expired for ENTERPRISE.
if (organisation.organisationClaimId === INTERNAL_CLAIM_ID.ENTERPRISE) {
return {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
};
}
for (const subscription of activeSubscriptions) {
const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
// Early return for users with an expired subscription.
if (subscription && subscription.status !== SubscriptionStatus.ACTIVE) {
return {
quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS,
};
}
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}
const currentQuota = ZLimitsSchema.parse(
'metadata' in price.product ? price.product.metadata : {},
);
// Use the subscription with the highest quota.
if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) {
quota = currentQuota;
remaining = structuredClone(quota);
}
}
// Assume all active subscriptions provide unlimited direct templates.
remaining.directTemplates = Infinity;
// Allow unlimited documents for users with an unlimited documents claim.
// This also allows "free" claim users without subscriptions if they have this flag.
if (organisation.organisationClaim.flags.unlimitedDocuments) {
return {
quota: PAID_PLAN_LIMITS,
remaining: PAID_PLAN_LIMITS,
};
}
const [documents, directTemplates] = await Promise.all([
prisma.document.count({
where: {
userId: user.id,
teamId: null,
team: {
organisationId: organisation.id,
},
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
@ -98,8 +99,9 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
}),
prisma.template.count({
where: {
userId: user.id,
teamId: null,
team: {
organisationId: organisation.id,
},
directLink: {
isNot: null,
},
@ -115,52 +117,3 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
remaining,
};
};
type HandleTeamLimitsOptions = {
email: string;
teamId: number;
};
const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
user: {
email,
},
},
},
},
include: {
subscription: true,
},
});
if (!team) {
throw new Error('Team not found');
}
const { subscription } = team;
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
return {
quota: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
remaining: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
};
}
return {
quota: structuredClone(TEAM_PLAN_LIMITS),
remaining: structuredClone(TEAM_PLAN_LIMITS),
};
};

View File

@ -1,20 +1,21 @@
import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetCheckoutSessionOptions = {
export type CreateCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
subscriptionMetadata?: Stripe.Metadata;
};
export const getCheckoutSession = async ({
export const createCheckoutSession = async ({
customerId,
priceId,
returnUrl,
subscriptionMetadata,
}: GetCheckoutSessionOptions) => {
}: CreateCheckoutSessionOptions) => {
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
@ -31,5 +32,11 @@ export const getCheckoutSession = async ({
},
});
if (!session.url) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to create checkout session',
});
}
return session.url;
};

View File

@ -0,0 +1,13 @@
import { stripe } from '@documenso/lib/server-only/stripe';
type CreateCustomerOptions = {
name: string;
email: string;
};
export const createCustomer = async ({ name, email }: CreateCustomerOptions) => {
return await stripe.customers.create({
name,
email,
});
};

View File

@ -1,20 +0,0 @@
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
type CreateTeamCustomerOptions = {
name: string;
email: string;
};
/**
* Create a Stripe customer for a given team.
*/
export const createTeamCustomer = async ({ name, email }: CreateTeamCustomerOptions) => {
return await stripe.customers.create({
name,
email,
metadata: {
type: STRIPE_CUSTOMER_TYPE.TEAM,
},
});
};

View File

@ -1,22 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
type DeleteCustomerPaymentMethodsOptions = {
customerId: string;
};
/**
* Delete all attached payment methods for a given customer.
*/
export const deleteCustomerPaymentMethods = async ({
customerId,
}: DeleteCustomerPaymentMethodsOptions) => {
const paymentMethods = await stripe.paymentMethods.list({
customer: customerId,
});
await Promise.all(
paymentMethods.data.map(async (paymentMethod) =>
stripe.paymentMethods.detach(paymentMethod.id),
),
);
};

View File

@ -1,13 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getCommunityPlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
};
export const getCommunityPlanPriceIds = async () => {
const prices = await getCommunityPlanPrices();
return prices.map((price) => price.id);
};

View File

@ -1,21 +1,4 @@
import type { User } from '@prisma/client';
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
/**
* Get a non team Stripe customer by email.
*/
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
email,
});
return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null;
};
export const getStripeCustomerById = async (stripeCustomerId: string) => {
try {
@ -26,85 +9,3 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
return null;
}
};
/**
* Get a stripe customer by user.
*
* Will create a Stripe customer and update the relevant user if one does not exist.
*/
export const getStripeCustomerByUser = async (
user: Pick<User, 'id' | 'customerId' | 'email' | 'name'>,
) => {
if (user.customerId) {
const stripeCustomer = await getStripeCustomerById(user.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer');
}
return {
user,
stripeCustomer,
};
}
let stripeCustomer = await getStripeCustomerByEmail(user.email);
const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted);
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL,
},
});
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
customerId: stripeCustomer.id,
},
});
// Sync subscriptions if the customer already exists for back filling the DB
// and local development.
if (isSyncRequired) {
await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => {
console.error(e);
});
}
return {
user: updatedUser,
stripeCustomer,
};
};
export const getStripeCustomerIdByUser = async (user: User) => {
if (user.customerId !== null) {
return user.customerId;
}
return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id);
};
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
const stripeSubscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
});
await Promise.all(
stripeSubscriptions.data.map(async (subscription) =>
onSubscriptionUpdated({
userId,
subscription,
}),
),
);
};

View File

@ -1,15 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of documents a user can create.
*/
export const getDocumentRelatedPrices = async () => {
return await getPricesByPlan([
STRIPE_PLAN_TYPE.REGULAR,
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.PLATFORM,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};

View File

@ -1,13 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getEnterprisePlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
};
export const getEnterprisePlanPriceIds = async () => {
const prices = await getEnterprisePlanPrices();
return prices.map((price) => price.id);
};

View File

@ -0,0 +1,84 @@
import { clone } from 'remeda';
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import {
INTERNAL_CLAIM_ID,
type InternalClaim,
internalClaims,
} from '@documenso/lib/types/subscription';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
export type InternalClaimPlans = {
[key in INTERNAL_CLAIM_ID]: InternalClaim & {
monthlyPrice?: Stripe.Price & {
product: Stripe.Product;
isVisibleInApp: boolean;
friendlyPrice: string;
};
yearlyPrice?: Stripe.Price & {
product: Stripe.Product;
isVisibleInApp: boolean;
friendlyPrice: string;
};
};
};
/**
* Returns the main Documenso plans from Stripe.
*/
export const getInternalClaimPlans = async (): Promise<InternalClaimPlans> => {
const { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
const plans: InternalClaimPlans = clone(internalClaims);
prices.forEach((price) => {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const productClaimId = product.metadata.claimId as INTERNAL_CLAIM_ID | undefined;
const isVisibleInApp = price.metadata.visibleInApp === 'true';
if (!productClaimId || !Object.values(INTERNAL_CLAIM_ID).includes(productClaimId)) {
return;
}
let usdPrice = toHumanPrice(price.unit_amount ?? 0);
if (price.recurring?.interval === 'month') {
if (product.metadata['isSeatBased'] === 'true') {
usdPrice = '50';
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
plans[productClaimId].monthlyPrice = {
...price,
isVisibleInApp,
product,
friendlyPrice: `$${usdPrice} ${price.currency.toUpperCase()}`.replace('.00', ''),
};
}
if (price.recurring?.interval === 'year') {
if (product.metadata['isSeatBased'] === 'true') {
usdPrice = '480';
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
plans[productClaimId].yearlyPrice = {
...price,
isVisibleInApp,
product,
friendlyPrice: `$${usdPrice} ${price.currency.toUpperCase()}`.replace('.00', ''),
};
}
});
return plans;
};

View File

@ -1,13 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getPlatformPlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.PLATFORM);
};
export const getPlatformPlanPriceIds = async () => {
const prices = await getPlatformPlanPrices();
return prices.map((price) => price.id);
};

View File

@ -1,60 +0,0 @@
import type Stripe from 'stripe';
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
// Utility type to handle usage of the `expand` option.
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export type GetPricesByIntervalOptions = {
/**
* Filter products by their meta 'plan' attribute.
*/
plans?: STRIPE_PLAN_TYPE[];
};
export const getPricesByInterval = async ({ plans }: GetPricesByIntervalOptions = {}) => {
let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
prices = prices.filter((price) => {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const filter = !plans || plans.includes(product.metadata?.plan as STRIPE_PLAN_TYPE);
// Filter out prices for products that are not active.
return product.active && filter;
});
const intervals: PriceIntervals = {
day: [],
week: [],
month: [],
year: [],
};
// Add each price to the correct interval.
for (const price of prices) {
if (price.recurring?.interval) {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
intervals[price.recurring.interval].push(price as PriceWithProduct);
}
}
// Order all prices by unit_amount.
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
return intervals;
};

View File

@ -1,17 +0,0 @@
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
const prices = await stripe.prices.list({
expand: ['data.product'],
limit: 100,
});
return prices.data.filter(
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
);
};

View File

@ -1,15 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the prices of items that count as the account's primary plan.
*/
export const getPrimaryAccountPlanPrices = async () => {
return await getPricesByPlan([
STRIPE_PLAN_TYPE.REGULAR,
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.PLATFORM,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};

View File

@ -1,17 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetProductByPriceIdOptions = {
priceId: string;
};
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
const { product } = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
if (typeof product === 'string' || 'deleted' in product) {
throw new Error('Product not found');
}
return product;
};

View File

@ -0,0 +1,42 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
export type GetSubscriptionOptions = {
userId: number;
organisationId: string;
};
export const getSubscription = async ({ organisationId, userId }: GetSubscriptionOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
subscription: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (!organisation.subscription) {
return null;
}
const stripeSubscription = await stripe.subscriptions.retrieve(organisation.subscription.planId, {
expand: ['items.data.price.product'],
});
return {
organisationSubscription: organisation.subscription,
stripeSubscription,
};
};

View File

@ -1,45 +0,0 @@
import type Stripe from 'stripe';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { AppError } from '@documenso/lib/errors/app-error';
import { getPricesByPlan } from './get-prices-by-plan';
export const getTeamPrices = async () => {
const prices = (await getPricesByPlan(STRIPE_PLAN_TYPE.TEAM)).filter((price) => price.active);
const monthlyPrice = prices.find((price) => price.recurring?.interval === 'month');
const yearlyPrice = prices.find((price) => price.recurring?.interval === 'year');
const priceIds = prices.map((price) => price.id);
if (!monthlyPrice || !yearlyPrice) {
throw new AppError('INVALID_CONFIG', {
message: 'Missing monthly or yearly price',
});
}
return {
monthly: {
friendlyInterval: 'Monthly',
interval: 'monthly',
...extractPriceData(monthlyPrice),
},
yearly: {
friendlyInterval: 'Yearly',
interval: 'yearly',
...extractPriceData(yearlyPrice),
},
priceIds,
} as const;
};
const extractPriceData = (price: Stripe.Price) => {
const product =
typeof price.product !== 'string' && !price.product.deleted ? price.product : null;
return {
priceId: price.id,
description: product?.description ?? '',
features: product?.features ?? [],
};
};

View File

@ -1,21 +0,0 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPrices = async () => {
return await getPricesByPlan([
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.PLATFORM,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};
/**
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPriceIds = async () => {
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
};

View File

@ -0,0 +1,13 @@
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
export const isPriceSeatsBased = async (priceId: string) => {
const foundStripePrice = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = foundStripePrice.product as Stripe.Product;
return product.metadata.isSeatBased === 'true';
};

View File

@ -1,128 +0,0 @@
import { type Subscription, type Team, type User } from '@prisma/client';
import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getTeamPrices } from './get-team-prices';
import { getTeamRelatedPriceIds } from './get-team-related-prices';
type TransferStripeSubscriptionOptions = {
/**
* The user to transfer the subscription to.
*/
user: User & { subscriptions: Subscription[] };
/**
* The team the subscription is associated with.
*/
team: Team & { subscription?: Subscription | null };
/**
* Whether to clear any current payment methods attached to the team.
*/
clearPaymentMethods: boolean;
};
/**
* Transfer the Stripe Team seats subscription from one user to another.
*
* Will create a new subscription for the new owner and cancel the old one.
*
* Returns the subscription that should be associated with the team, null if
* no subscription is needed (for early adopter plan).
*/
export const transferTeamSubscription = async ({
user,
team,
clearPaymentMethods,
}: TransferStripeSubscriptionOptions) => {
const teamCustomerId = team.customerId;
if (!teamCustomerId) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Missing customer ID.',
});
}
const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
getTeamRelatedPriceIds(),
getTeamPrices(),
]);
const teamSubscriptionRequired = !subscriptionsContainsActivePlan(
user.subscriptions,
teamRelatedPlanPriceIds,
);
let teamSubscription: Stripe.Subscription | null = null;
if (team.subscription) {
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
if (!teamSubscription) {
throw new Error('Could not find the current subscription.');
}
if (clearPaymentMethods) {
await deleteCustomerPaymentMethods({ customerId: teamCustomerId });
}
}
await stripe.customers.update(teamCustomerId, {
name: user.name ?? team.name,
email: user.email,
});
// If team subscription is required and the team does not have a subscription, create one.
if (teamSubscriptionRequired && !teamSubscription) {
const numberOfSeats = await prisma.teamMember.count({
where: {
teamId: team.id,
},
});
const teamSeatPriceId = teamSeatPrices.monthly.priceId;
teamSubscription = await stripe.subscriptions.create({
customer: teamCustomerId,
items: [
{
price: teamSeatPriceId,
quantity: numberOfSeats,
},
],
metadata: {
teamId: team.id.toString(),
},
});
}
// If no team subscription is required, cancel the current team subscription if it exists.
if (!teamSubscriptionRequired && teamSubscription) {
try {
// Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount.
await stripe.subscriptions.update(teamSubscription.id, {
items: teamSubscription.items.data.map((item) => ({
id: item.id,
quantity: 0,
})),
});
await stripe.subscriptions.cancel(teamSubscription.id, {
invoice_now: true,
prorate: false,
});
} catch (e) {
// Do not error out since we can't easily undo the transfer.
// Todo: Teams - Alert us.
}
return null;
}
return teamSubscription;
};

View File

@ -1,6 +1,12 @@
import type { OrganisationClaim, Subscription } from '@prisma/client';
import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { appLog } from '@documenso/lib/utils/debugger';
import { prisma } from '@documenso/prisma';
import { isPriceSeatsBased } from './is-price-seats-based';
export type UpdateSubscriptionItemQuantityOptions = {
subscriptionId: string;
@ -42,3 +48,57 @@ export const updateSubscriptionItemQuantity = async ({
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
};
/**
* Checks whether the member count should be synced with a given Stripe subscription.
*
* If the subscription is not "seat" based, it will be ignored.
*
* @param subscription - The subscription to sync the member count with.
* @param organisationClaim - The organisation claim
* @param quantity - The amount to sync the Stripe item with
* @returns
*/
export const syncMemberCountWithStripeSeatPlan = async (
subscription: Subscription,
organisationClaim: OrganisationClaim,
quantity: number,
) => {
const maximumMemberCount = organisationClaim.memberCount;
// Infinite seats means no sync needed.
if (maximumMemberCount === 0) {
return;
}
const syncMemberCountWithStripe = await isPriceSeatsBased(subscription.priceId);
// Throw error if quantity exceeds maximum member count and the subscription is not seats based.
if (quantity > maximumMemberCount && !syncMemberCountWithStripe) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'Maximum member count reached',
});
}
// Bill the user with the new quantity.
if (syncMemberCountWithStripe) {
appLog('BILLING', 'Updating seat based plan');
await updateSubscriptionItemQuantity({
priceId: subscription.priceId,
subscriptionId: subscription.planId,
quantity,
});
// This should be automatically updated after the Stripe webhook is fired
// but we just manually adjust it here as well to avoid any race conditions.
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
memberCount: quantity,
},
});
}
};

View File

@ -1,13 +1,11 @@
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 { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { onSubscriptionCreated } from './on-subscription-created';
import { onSubscriptionDeleted } from './on-subscription-deleted';
import { onSubscriptionUpdated } from './on-subscription-updated';
@ -65,78 +63,18 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
/**
* Notes:
* - Dropped invoice.payment_succeeded
* - Dropped invoice.payment_failed
* - Dropped checkout-session.completed
*/
return await match(event.type)
.with('checkout.session.completed', async () => {
.with('customer.subscription.created', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session;
const subscription = event.data.object as Stripe.Subscription;
const customerId =
typeof session.customer === 'string' ? session.customer : session.customer?.id;
// Attempt to get the user ID from the client reference id.
let userId = Number(session.client_reference_id);
// If the user ID is not found, attempt to get it from the Stripe customer metadata.
if (!userId && customerId) {
const customer = await stripe.customers.retrieve(customerId);
if (!customer.deleted) {
userId = Number(customer.metadata.userId);
}
}
// Finally, attempt to get the user ID from the subscription within the database.
if (!userId && customerId) {
const result = await prisma.user.findFirst({
select: {
id: true,
},
where: {
customerId,
},
});
if (result?.id) {
userId = result.id;
}
}
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription?.id;
if (!subscriptionId) {
return Response.json(
{ success: false, message: 'Invalid session' } satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Handle team creation after seat checkout.
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
await handleTeamSeatCheckout({ subscription });
return Response.json(
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
{ status: 200 },
);
}
// Validate user ID.
if (!userId || Number.isNaN(userId)) {
return Response.json(
{
success: false,
message: 'Invalid session or missing user ID',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId, subscription });
await onSubscriptionCreated({ subscription });
return Response.json(
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
@ -147,254 +85,14 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return Response.json(
{
success: false,
message: 'No team associated with subscription found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return Response.json(
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const result = await prisma.user.findFirst({
select: {
id: true,
},
where: {
customerId,
},
});
if (!result?.id) {
return Response.json(
{
success: false,
message: 'User not found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId: result.id, subscription });
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;
const previousAttributes = event.data
.previous_attributes as Partial<Stripe.Subscription> | null;
if (invoice.billing_reason !== 'subscription_cycle') {
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
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 Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return Response.json(
{
success: false,
message: 'No team associated with subscription found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const result = await prisma.user.findFirst({
select: {
id: true,
},
where: {
customerId,
},
});
if (!result?.id) {
return Response.json(
{
success: false,
message: 'User not found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId: result.id, subscription });
await onSubscriptionUpdated({ subscription, previousAttributes });
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
const invoice = event.data.object as Stripe.Invoice;
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
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 Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return Response.json(
{
success: false,
message: 'No team associated with subscription found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
const result = await prisma.user.findFirst({
select: {
id: true,
},
where: {
customerId,
},
});
if (!result?.id) {
return Response.json(
{
success: false,
message: 'User not found',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
await onSubscriptionUpdated({ userId: result.id, subscription });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
{ status: 200 },
);
})
@ -424,6 +122,13 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
} catch (err) {
console.error(err);
if (err instanceof Response) {
const message = await err.json();
console.error(message);
return err;
}
return Response.json(
{
success: false,
@ -433,21 +138,3 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
);
}
};
export type HandleTeamSeatCheckoutOptions = {
subscription: Stripe.Subscription;
};
const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => {
if (subscription.metadata?.pendingTeamId === undefined) {
throw new Error('Missing pending team ID');
}
const pendingTeamId = Number(subscription.metadata.pendingTeamId);
if (Number.isNaN(pendingTeamId)) {
throw new Error('Invalid pending team ID');
}
return await createTeamFromPendingTeam({ pendingTeamId, subscription }).then((team) => team.id);
};

View File

@ -0,0 +1,214 @@
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import {
createOrganisation,
createOrganisationClaimUpsertData,
} from '@documenso/lib/server-only/organisation/create-organisation';
import { type Stripe } from '@documenso/lib/server-only/stripe';
import type {
InternalClaim,
StripeOrganisationCreateMetadata,
} from '@documenso/lib/types/subscription';
import {
INTERNAL_CLAIM_ID,
ZStripeOrganisationCreateMetadataSchema,
} from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { extractStripeClaim } from './on-subscription-updated';
export type OnSubscriptionCreatedOptions = {
subscription: Stripe.Subscription;
};
type StripeWebhookResponse = {
success: boolean;
message: string;
};
/**
* Todo: We might want to pull this into a job so we can do steps. Since if organisation creation passes but
* fails after this would be automatically rerun by Stripe, which means duplicate organisations can be
* potentially created.
*/
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
// Todo: logging
if (subscription.items.data.length !== 1) {
console.error('No support for multiple items');
throw Response.json(
{
success: false,
message: 'No support for multiple items',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscriptionItem = subscription.items.data[0];
const claim = await extractStripeClaim(subscriptionItem.price);
// Todo: logging
if (!claim) {
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
throw Response.json(
{
success: false,
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const organisationCreateData = subscription.metadata?.organisationCreateData;
// A new subscription can be for an existing organisation or a new one.
const organisationId = organisationCreateData
? await handleOrganisationCreate({
customerId,
claim,
unknownCreateData: organisationCreateData,
})
: await handleOrganisationUpdate({
customerId,
claim,
});
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.create({
data: {
organisationId,
status,
customerId,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
};
type HandleOrganisationCreateOptions = {
customerId: string;
claim: InternalClaim;
unknownCreateData: string;
};
/**
* Handles the creation of an organisation.
*/
const handleOrganisationCreate = async ({
customerId,
claim,
unknownCreateData,
}: HandleOrganisationCreateOptions) => {
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(
JSON.parse(unknownCreateData),
);
if (!parseResult.success) {
console.error('Invalid organisation create flow data');
throw Response.json(
{
success: false,
message: 'Invalid organisation create flow data',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
organisationCreateFlowData = parseResult.data;
const createdOrganisation = await createOrganisation({
name: organisationCreateFlowData.organisationName,
userId: organisationCreateFlowData.userId,
type: OrganisationType.ORGANISATION,
customerId,
claim,
});
return createdOrganisation.id;
};
type HandleOrganisationUpdateOptions = {
customerId: string;
claim: InternalClaim;
};
/**
* Handles the updating an exist organisation claims.
*/
const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisationUpdateOptions) => {
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
subscription: true,
organisationClaim: true,
},
});
if (!organisation) {
throw Response.json(
{
success: false,
message: `Organisation not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
// Todo: logging
if (organisation.subscription) {
console.error('Organisation already has a subscription');
// This should never happen
throw Response.json(
{
success: false,
message: `Organisation already has a subscription`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
let newOrganisationType: OrganisationType = OrganisationType.ORGANISATION;
// Keep the organisation as personal if the claim is for an individual.
if (
organisation.type === OrganisationType.PERSONAL &&
claim.id === INTERNAL_CLAIM_ID.INDIVIDUAL
) {
newOrganisationType = OrganisationType.PERSONAL;
}
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
type: newOrganisationType,
organisationClaim: {
update: {
originalSubscriptionClaimId: claim.id,
...createOrganisationClaimUpsertData(claim),
},
},
},
});
return organisation.id;
};

View File

@ -1,59 +1,163 @@
import type { Prisma } from '@prisma/client';
import { SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
export type OnSubscriptionUpdatedOptions = {
userId?: number;
teamId?: number;
subscription: Stripe.Subscription;
previousAttributes: Partial<Stripe.Subscription> | null;
};
type StripeWebhookResponse = {
success: boolean;
message: string;
};
export const onSubscriptionUpdated = async ({
userId,
teamId,
subscription,
previousAttributes,
}: OnSubscriptionUpdatedOptions) => {
await prisma.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId),
);
};
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
export const mapStripeSubscriptionToPrismaUpsertAction = (
subscription: Stripe.Subscription,
userId?: number,
teamId?: number,
): Prisma.SubscriptionUpsertArgs => {
if ((!userId && !teamId) || (userId && teamId)) {
throw new Error('Either userId or teamId must be provided.');
// Todo: logging
if (subscription.items.data.length !== 1) {
console.error('No support for multiple items');
throw Response.json(
{
success: false,
message: 'No support for multiple items',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
organisationClaim: true,
subscription: true,
},
});
if (!organisation) {
throw Response.json(
{
success: false,
message: `Organisation not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
if (organisation.subscription?.planId !== subscription.id) {
console.error('[WARNING]: Organisation has two subscriptions');
}
const previousItem = previousAttributes?.items?.data[0];
const updatedItem = subscription.items.data[0];
const previousSubscriptionClaimId = previousItem
? await extractStripeClaimId(previousItem.price)
: null;
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
if (!updatedSubscriptionClaim) {
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
throw Response.json(
{
success: false,
message: `Subscription claim on ${updatedItem.price.id} not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
return {
where: {
planId: subscription.id,
},
create: {
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId: userId ?? null,
teamId: teamId ?? null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
};
await prisma.$transaction(async (tx) => {
await tx.subscription.update({
where: {
planId: subscription.id,
},
data: {
organisationId: organisation.id,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
// Override current organisation claim if new one is found.
if (newClaimFound) {
await tx.organisationClaim.update({
where: {
id: organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
},
});
}
});
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
* The order of priority is:
* 1. Price metadata
* 2. Product metadata
*
* @returns The claimId or null if no claimId is found.
*/
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
if (priceId.metadata.claimId) {
return priceId.metadata.claimId;
}
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
const product = await stripe.products.retrieve(productId);
return product.metadata.claimId || null;
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
*/
export const extractStripeClaim = async (priceId: Stripe.Price) => {
const claimId = await extractStripeClaimId(priceId);
if (!claimId) {
return null;
}
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
where: { id: claimId },
});
if (!subscriptionClaim) {
console.error(`Subscription claim ${claimId} not found`);
return null;
}
return subscriptionClaim;
};

View File

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

View File

@ -1,65 +0,0 @@
import type { Subscription } from '@prisma/client';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { getEnterprisePlanPriceIds } from '../stripe/get-enterprise-plan-prices';
export type IsUserEnterpriseOptions = {
userId: number;
teamId?: number;
};
/**
* Whether the user is enterprise, or has permission to use enterprise features on
* behalf of their team.
*
* It is assumed that the provided user is part of the provided team.
*/
export const isUserEnterprise = async ({
userId,
teamId,
}: IsUserEnterpriseOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (!IS_BILLING_ENABLED()) {
return false;
}
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
subscriptions: true,
},
},
},
})
.then((team) => team.owner.subscriptions);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
subscriptions: true,
},
})
.then((user) => user.subscriptions);
}
if (subscriptions.length === 0) {
return false;
}
const enterprisePlanPriceIds = await getEnterprisePlanPriceIds();
return subscriptionsContainsActivePlan(subscriptions, enterprisePlanPriceIds, true);
};

View File

@ -1,62 +0,0 @@
import type { Document, Subscription } from '@prisma/client';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
export type IsDocumentPlatformOptions = Pick<Document, 'userId' | 'teamId'>;
/**
* Whether the user is platform, or has permission to use platform features on
* behalf of their team.
*
* It is assumed that the provided user is part of the provided team.
*/
export const isDocumentPlatform = async ({
userId,
teamId,
}: IsDocumentPlatformOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (!IS_BILLING_ENABLED()) {
return true;
}
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
subscriptions: true,
},
},
},
})
.then((team) => team.owner.subscriptions);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
subscriptions: true,
},
})
.then((user) => user.subscriptions);
}
if (subscriptions.length === 0) {
return false;
}
const platformPlanPriceIds = await getPlatformPlanPriceIds();
return subscriptionsContainsActivePlan(subscriptions, platformPlanPriceIds);
};

View File

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { RecipientRole } from '@prisma/client';
import { OrganisationType, RecipientRole } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@ -18,9 +18,9 @@ export interface TemplateDocumentInviteProps {
assetBaseUrl: string;
role: RecipientRole;
selfSigner: boolean;
isTeamInvite: boolean;
teamName?: string;
includeSenderDetails?: boolean;
organisationType?: OrganisationType;
}
export const TemplateDocumentInvite = ({
@ -30,9 +30,9 @@ export const TemplateDocumentInvite = ({
assetBaseUrl,
role,
selfSigner,
isTeamInvite,
teamName,
includeSenderDetails,
organisationType,
}: TemplateDocumentInviteProps) => {
const { _ } = useLingui();
@ -50,21 +50,28 @@ export const TemplateDocumentInvite = ({
<Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{match({ selfSigner, isTeamInvite, includeSenderDetails, teamName })
{match({ selfSigner, organisationType, includeSenderDetails, teamName })
.with({ selfSigner: true }, () => (
<Trans>
Please {_(actionVerb).toLowerCase()} your document
<br />"{documentName}"
</Trans>
))
.with({ isTeamInvite: true, includeSenderDetails: true, teamName: P.string }, () => (
<Trans>
{inviterName} on behalf of "{teamName}" has invited you to{' '}
{_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
))
.with({ isTeamInvite: true, teamName: P.string }, () => (
.with(
{
organisationType: OrganisationType.ORGANISATION,
includeSenderDetails: true,
teamName: P.string,
},
() => (
<Trans>
{inviterName} on behalf of "{teamName}" has invited you to{' '}
{_(actionVerb).toLowerCase()}
<br />"{documentName}"
</Trans>
),
)
.with({ organisationType: OrganisationType.ORGANISATION, teamName: P.string }, () => (
<Trans>
{teamName} has invited you to {_(actionVerb).toLowerCase()}
<br />"{documentName}"

View File

@ -2,6 +2,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { RecipientRole } from '@prisma/client';
import { OrganisationType } from '@prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@ -15,10 +16,10 @@ export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInvitePro
customBody?: string;
role: RecipientRole;
selfSigner?: boolean;
isTeamInvite?: boolean;
teamName?: string;
teamEmail?: string;
includeSenderDetails?: boolean;
organisationType?: OrganisationType;
};
export const DocumentInviteEmailTemplate = ({
@ -30,9 +31,9 @@ export const DocumentInviteEmailTemplate = ({
customBody,
role,
selfSigner = false,
isTeamInvite = false,
teamName = '',
includeSenderDetails,
organisationType,
}: DocumentInviteEmailTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
@ -41,7 +42,7 @@ export const DocumentInviteEmailTemplate = ({
let previewText = msg`${inviterName} has invited you to ${action} ${documentName}`;
if (isTeamInvite) {
if (organisationType === OrganisationType.ORGANISATION) {
previewText = includeSenderDetails
? msg`${inviterName} on behalf of "${teamName}" has invited you to ${action} ${documentName}`
: msg`${teamName} has invited you to ${action} ${documentName}`;
@ -82,7 +83,7 @@ export const DocumentInviteEmailTemplate = ({
assetBaseUrl={assetBaseUrl}
role={role}
selfSigner={selfSigner}
isTeamInvite={isTeamInvite}
organisationType={organisationType}
teamName={teamName}
includeSenderDetails={includeSenderDetails}
/>
@ -91,7 +92,7 @@ export const DocumentInviteEmailTemplate = ({
<Container className="mx-auto mt-12 max-w-xl">
<Section>
{!isTeamInvite && (
{organisationType === OrganisationType.PERSONAL && (
<Text className="my-4 text-base font-semibold">
<Trans>
{inviterName}{' '}

View File

@ -2,8 +2,6 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import {
Body,
Button,
@ -20,27 +18,25 @@ import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type TeamInviteEmailProps = {
export type OrganisationInviteEmailProps = {
assetBaseUrl: string;
baseUrl: string;
senderName: string;
teamName: string;
teamUrl: string;
organisationName: string;
token: string;
};
export const TeamInviteEmailTemplate = ({
export const OrganisationInviteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
senderName = 'John Doe',
teamName = 'Team Name',
teamUrl = 'demo',
organisationName = 'Organisation Name',
token = '',
}: TeamInviteEmailProps) => {
}: OrganisationInviteEmailProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Accept invitation to join a team on Documenso`;
const previewText = msg`Accept invitation to join an organisation on Documenso`;
return (
<Html>
@ -70,15 +66,15 @@ export const TeamInviteEmailTemplate = ({
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
<Trans>Join {teamName} on Documenso</Trans>
<Trans>Join {organisationName} on Documenso</Trans>
</Text>
<Text className="my-1 text-center text-base">
<Trans>You have been invited to join the following team</Trans>
<Trans>You have been invited to join the following organisation</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{formatTeamUrl(teamUrl, baseUrl)}
{organisationName}
</div>
<Text className="my-1 text-center text-base">
@ -90,13 +86,13 @@ export const TeamInviteEmailTemplate = ({
<Section className="mb-6 mt-6 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={`${baseUrl}/team/invite/${token}`}
href={`${baseUrl}/organisation/invite/${token}`}
>
<Trans>Accept</Trans>
</Button>
<Button
className="ml-4 inline-flex items-center justify-center rounded-lg bg-gray-50 px-6 py-3 text-center text-sm font-medium text-slate-600 no-underline"
href={`${baseUrl}/team/decline/${token}`}
href={`${baseUrl}/organisation/decline/${token}`}
>
<Trans>Decline</Trans>
</Button>
@ -115,4 +111,4 @@ export const TeamInviteEmailTemplate = ({
);
};
export default TeamInviteEmailTemplate;
export default OrganisationInviteEmailTemplate;

View File

@ -2,34 +2,32 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type TeamJoinEmailProps = {
export type OrganisationJoinEmailProps = {
assetBaseUrl: string;
baseUrl: string;
memberName: string;
memberEmail: string;
teamName: string;
teamUrl: string;
organisationName: string;
organisationUrl: string;
};
export const TeamJoinEmailTemplate = ({
export const OrganisationJoinEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
memberName = 'John Doe',
memberEmail = 'johndoe@documenso.com',
teamName = 'Team Name',
teamUrl = 'demo',
}: TeamJoinEmailProps) => {
organisationName = 'Organisation Name',
organisationUrl = 'demo',
}: OrganisationJoinEmailProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`A team member has joined a team on Documenso`;
const previewText = msg`A member has joined your organisation on Documenso`;
return (
<Html>
@ -59,17 +57,11 @@ export const TeamJoinEmailTemplate = ({
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
<Trans>
{memberName || memberEmail} joined the team {teamName} on Documenso
</Trans>
</Text>
<Text className="my-1 text-center text-base">
<Trans>{memberEmail} joined the following team</Trans>
<Trans>A new member has joined your organisation {organisationName}</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{formatTeamUrl(teamUrl, baseUrl)}
{memberName || memberEmail}
</div>
</Section>
</Container>
@ -85,4 +77,4 @@ export const TeamJoinEmailTemplate = ({
);
};
export default TeamJoinEmailTemplate;
export default OrganisationJoinEmailTemplate;

View File

@ -2,34 +2,32 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type TeamLeaveEmailProps = {
export type OrganisationLeaveEmailProps = {
assetBaseUrl: string;
baseUrl: string;
memberName: string;
memberEmail: string;
teamName: string;
teamUrl: string;
organisationName: string;
organisationUrl: string;
};
export const TeamLeaveEmailTemplate = ({
export const OrganisationLeaveEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
memberName = 'John Doe',
memberEmail = 'johndoe@documenso.com',
teamName = 'Team Name',
teamUrl = 'demo',
}: TeamLeaveEmailProps) => {
organisationName = 'Organisation Name',
organisationUrl = 'demo',
}: OrganisationLeaveEmailProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`A team member has left a team on Documenso`;
const previewText = msg`A member has left your organisation on Documenso`;
return (
<Html>
@ -59,17 +57,11 @@ export const TeamLeaveEmailTemplate = ({
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
<Trans>
{memberName || memberEmail} left the team {teamName} on Documenso
</Trans>
</Text>
<Text className="my-1 text-center text-base">
<Trans>{memberEmail} left the following team</Trans>
<Trans>A member has left your organisation {organisationName}</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{formatTeamUrl(teamUrl, baseUrl)}
{memberName || memberEmail}
</div>
</Section>
</Container>
@ -85,4 +77,4 @@ export const TeamLeaveEmailTemplate = ({
);
};
export default TeamLeaveEmailTemplate;
export default OrganisationLeaveEmailTemplate;

View File

@ -12,29 +12,21 @@ export type TeamDeleteEmailProps = {
assetBaseUrl: string;
baseUrl: string;
teamUrl: string;
isOwner: boolean;
};
export const TeamDeleteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
teamUrl = 'demo',
isOwner = false,
}: TeamDeleteEmailProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = isOwner
? msg`Your team has been deleted`
: msg`A team you were a part of has been deleted`;
const previewText = msg`A team you were a part of has been deleted`;
const title = isOwner
? msg`Your team has been deleted`
: msg`A team you were a part of has been deleted`;
const title = msg`A team you were a part of has been deleted`;
const description = isOwner
? msg`The following team has been deleted by you`
: msg`The following team has been deleted by its owner. You will no longer be able to access this team and its documents`;
const description = msg`The following team has been deleted. You will no longer be able to access this team and its documents`;
return (
<Html>

View File

@ -1,103 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import { Body, Button, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type TeamTransferRequestTemplateProps = {
assetBaseUrl: string;
baseUrl: string;
senderName: string;
teamName: string;
teamUrl: string;
token: string;
};
export const TeamTransferRequestTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
senderName = 'John Doe',
teamName = 'Team Name',
teamUrl = 'demo',
token = '',
}: TeamTransferRequestTemplateProps) => {
const { _ } = useLingui();
const previewText = msg`Accept team transfer request on Documenso`;
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
<TemplateImage
assetBaseUrl={assetBaseUrl}
className="mb-4 h-6 p-2"
staticAsset="logo.png"
/>
<Section>
<TemplateImage
className="mx-auto"
assetBaseUrl={assetBaseUrl}
staticAsset="add-user.png"
/>
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
<Trans>{teamName} ownership transfer request</Trans>
</Text>
<Text className="my-1 text-center text-base">
<Trans>
<span className="font-bold">{senderName}</span> has requested that you take
ownership of the following team
</Trans>
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{formatTeamUrl(teamUrl, baseUrl)}
</div>
<Text className="text-center text-sm">
<Trans>
By accepting this request, you will take responsibility for any billing items
associated with this team.
</Trans>
</Text>
<Section className="mb-6 mt-6 text-center">
<Button
className="bg-documenso-500 ml-2 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={`${baseUrl}/team/verify/transfer/${token}`}
>
<Trans>Accept</Trans>
</Button>
</Section>
</Section>
<Text className="text-center text-xs">
<Trans>Link expires in 1 hour.</Trans>
</Text>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default TeamTransferRequestTemplate;

View File

@ -0,0 +1,33 @@
import { createContext, useContext } from 'react';
import React from 'react';
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
type OrganisationProviderValue = OrganisationSession;
interface OrganisationProviderProps {
children: React.ReactNode;
organisation: OrganisationProviderValue | null;
}
const OrganisationContext = createContext<OrganisationProviderValue | null>(null);
export const useCurrentOrganisation = () => {
const context = useContext(OrganisationContext);
if (!context) {
throw new Error('useCurrentOrganisation must be used within a OrganisationProvider');
}
return context;
};
export const useOptionalCurrentOrganisation = () => {
return useContext(OrganisationContext);
};
export const OrganisationProvider = ({ children, organisation }: OrganisationProviderProps) => {
return (
<OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>
);
};

View File

@ -6,13 +6,15 @@ import { useLocation } from 'react-router';
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { type TGetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
import { trpc } from '@documenso/trpc/client';
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
import { SKIP_QUERY_BATCH_META } from '../../constants/trpc';
export type AppSession = {
session: Session;
user: SessionUser;
teams: TGetTeamsResponse;
organisations: TGetOrganisationSessionResponse;
};
interface SessionProviderProps {
@ -67,15 +69,17 @@ export const SessionProvider = ({ children, initialSession }: SessionProviderPro
return;
}
const teams = await trpc.team.getTeams.query().catch(() => {
// Todo: (RR7) Log
return [];
});
const organisations = await trpc.organisation.internal.getOrganisationSession
.query(undefined, SKIP_QUERY_BATCH_META.trpc)
.catch(() => {
// Todo: (RR7) Log
return [];
});
setSession({
session: newSession.session,
user: newSession.user,
teams,
organisations,
});
}, []);

View File

@ -31,6 +31,7 @@ export const USER_SECURITY_AUDIT_LOG_MAP: Record<string, string> = {
PASSKEY_UPDATED: 'Passkey updated',
PASSWORD_RESET: 'Password reset',
PASSWORD_UPDATE: 'Password updated',
SESSION_REVOKED: 'Session revoked',
SIGN_OUT: 'Signed Out',
SIGN_IN: 'Signed In',
SIGN_IN_FAIL: 'Sign in attempt failed',

View File

@ -1,12 +1,18 @@
export enum STRIPE_CUSTOMER_TYPE {
INDIVIDUAL = 'individual',
TEAM = 'team',
}
import { SubscriptionStatus } from '@prisma/client';
export enum STRIPE_PLAN_TYPE {
REGULAR = 'regular',
TEAM = 'team',
COMMUNITY = 'community',
FREE = 'free',
INDIVIDUAL = 'individual',
PRO = 'pro',
EARLY_ADOPTER = 'earlyAdopter',
PLATFORM = 'platform',
ENTERPRISE = 'enterprise',
}
export const FREE_TIER_DOCUMENT_QUOTA = 5;
export const SUBSCRIPTION_STATUS_MAP = {
[SubscriptionStatus.ACTIVE]: 'Active',
[SubscriptionStatus.INACTIVE]: 'Inactive',
[SubscriptionStatus.PAST_DUE]: 'Past Due',
};

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