mirror of
https://github.com/documenso/documenso.git
synced 2025-11-18 10:42:01 +10:00
fix: merge conflicts
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>,
|
||||
) => {
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@ -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}`;
|
||||
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -17,7 +17,7 @@ export const apiSignin = async ({
|
||||
page,
|
||||
email = 'example@documenso.com',
|
||||
password = 'password',
|
||||
redirectPath = '/documents',
|
||||
redirectPath = '/',
|
||||
}: LoginOptions) => {
|
||||
const { request } = page.context();
|
||||
|
||||
|
||||
9
packages/app-tests/e2e/fixtures/generic.ts
Normal file
9
packages/app-tests/e2e/fixtures/generic.ts
Normal 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();
|
||||
};
|
||||
@ -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
565
packages/app-tests/e2e/organisations/manage-organisation.spec.ts
Normal file
565
packages/app-tests/e2e/organisations/manage-organisation.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
@ -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`,
|
||||
});
|
||||
|
||||
|
||||
@ -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!');
|
||||
});
|
||||
@ -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',
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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`);
|
||||
});
|
||||
|
||||
82
packages/app-tests/e2e/user/auth-sessions.spec.ts
Normal file
82
packages/app-tests/e2e/user/auth-sessions.spec.ts
Normal 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');
|
||||
});
|
||||
@ -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' });
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
|
||||
|
||||
@ -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: [
|
||||
|
||||
BIN
packages/assets/fonts/caveat-variablefont_wght.ttf
Normal file
BIN
packages/assets/fonts/caveat-variablefont_wght.ttf
Normal file
Binary file not shown.
BIN
packages/assets/fonts/inter-italic-variablefont_opsz,wght.ttf
Normal file
BIN
packages/assets/fonts/inter-italic-variablefont_opsz,wght.ttf
Normal file
Binary file not shown.
BIN
packages/assets/fonts/inter-variablefont_opsz,wght.ttf
Normal file
BIN
packages/assets/fonts/inter-variablefont_opsz,wght.ttf
Normal file
Binary file not shown.
@ -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();
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from './server/lib/errors/errors';
|
||||
export * from './server/lib/errors/error-codes';
|
||||
|
||||
@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -114,7 +114,7 @@ export const passkeyRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
return c.json(
|
||||
{
|
||||
url: '/documents',
|
||||
url: '/',
|
||||
},
|
||||
200,
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -22,7 +22,7 @@ export const useLimits = () => {
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
initialValue?: TLimitsResponseSchema;
|
||||
teamId?: number;
|
||||
teamId: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
|
||||
@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
13
packages/ee/server-only/stripe/create-customer.ts
Normal file
13
packages/ee/server-only/stripe/create-customer.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
84
packages/ee/server-only/stripe/get-internal-claim-plans.ts
Normal file
84
packages/ee/server-only/stripe/get-internal-claim-plans.ts
Normal 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;
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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),
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
]);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
42
packages/ee/server-only/stripe/get-subscription.ts
Normal file
42
packages/ee/server-only/stripe/get-subscription.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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 ?? [],
|
||||
};
|
||||
};
|
||||
@ -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));
|
||||
};
|
||||
13
packages/ee/server-only/stripe/is-price-seats-based.ts
Normal file
13
packages/ee/server-only/stripe/is-price-seats-based.ts
Normal 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';
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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}"
|
||||
|
||||
@ -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}{' '}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
33
packages/lib/client-only/providers/organisation.tsx
Normal file
33
packages/lib/client-only/providers/organisation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
Reference in New Issue
Block a user