feat: add team user management endpoints to api (#1416)
## Description Adds user management capabilities to our current API. Allows for adding, removing, listing and updating members of a given team using a valid API token. ## Related Issue N/A ## Changes Made - Added an endpoint for inviting a team member - Added an endpoint for removing a team member - Added an endpoint for updating a team member - Added an endpoint for listing team members ## Testing Performed Tests were written for this feature request
This commit is contained in:
@@ -12,10 +12,12 @@ import {
|
|||||||
ZDeleteFieldMutationSchema,
|
ZDeleteFieldMutationSchema,
|
||||||
ZDeleteRecipientMutationSchema,
|
ZDeleteRecipientMutationSchema,
|
||||||
ZDownloadDocumentSuccessfulSchema,
|
ZDownloadDocumentSuccessfulSchema,
|
||||||
|
ZFindTeamMembersResponseSchema,
|
||||||
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
ZGenerateDocumentFromTemplateMutationResponseSchema,
|
||||||
ZGenerateDocumentFromTemplateMutationSchema,
|
ZGenerateDocumentFromTemplateMutationSchema,
|
||||||
ZGetDocumentsQuerySchema,
|
ZGetDocumentsQuerySchema,
|
||||||
ZGetTemplatesQuerySchema,
|
ZGetTemplatesQuerySchema,
|
||||||
|
ZInviteTeamMemberMutationSchema,
|
||||||
ZNoBodyMutationSchema,
|
ZNoBodyMutationSchema,
|
||||||
ZResendDocumentForSigningMutationSchema,
|
ZResendDocumentForSigningMutationSchema,
|
||||||
ZSendDocumentForSigningMutationSchema,
|
ZSendDocumentForSigningMutationSchema,
|
||||||
@@ -26,13 +28,17 @@ import {
|
|||||||
ZSuccessfulGetDocumentResponseSchema,
|
ZSuccessfulGetDocumentResponseSchema,
|
||||||
ZSuccessfulGetTemplateResponseSchema,
|
ZSuccessfulGetTemplateResponseSchema,
|
||||||
ZSuccessfulGetTemplatesResponseSchema,
|
ZSuccessfulGetTemplatesResponseSchema,
|
||||||
|
ZSuccessfulInviteTeamMemberResponseSchema,
|
||||||
ZSuccessfulRecipientResponseSchema,
|
ZSuccessfulRecipientResponseSchema,
|
||||||
|
ZSuccessfulRemoveTeamMemberResponseSchema,
|
||||||
ZSuccessfulResendDocumentResponseSchema,
|
ZSuccessfulResendDocumentResponseSchema,
|
||||||
ZSuccessfulResponseSchema,
|
ZSuccessfulResponseSchema,
|
||||||
ZSuccessfulSigningResponseSchema,
|
ZSuccessfulSigningResponseSchema,
|
||||||
|
ZSuccessfulUpdateTeamMemberResponseSchema,
|
||||||
ZUnsuccessfulResponseSchema,
|
ZUnsuccessfulResponseSchema,
|
||||||
ZUpdateFieldMutationSchema,
|
ZUpdateFieldMutationSchema,
|
||||||
ZUpdateRecipientMutationSchema,
|
ZUpdateRecipientMutationSchema,
|
||||||
|
ZUpdateTeamMemberMutationSchema,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
const c = initContract();
|
const c = initContract();
|
||||||
@@ -273,6 +279,61 @@ export const ApiContractV1 = c.router(
|
|||||||
},
|
},
|
||||||
summary: 'Delete a field from a document',
|
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,
|
baseHeaders: ZAuthorizationHeadersSchema,
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
|
|||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
|
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 type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
|
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
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 { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
|
||||||
@@ -49,7 +51,12 @@ import {
|
|||||||
} from '@documenso/lib/universal/upload/server-actions';
|
} from '@documenso/lib/universal/upload/server-actions';
|
||||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import {
|
||||||
|
DocumentDataType,
|
||||||
|
DocumentStatus,
|
||||||
|
SigningStatus,
|
||||||
|
TeamMemberRole,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { ApiContractV1 } from './contract';
|
import { ApiContractV1 } from './contract';
|
||||||
import { authenticatedMiddleware } from './middleware/authenticated';
|
import { authenticatedMiddleware } from './middleware/authenticated';
|
||||||
@@ -1279,4 +1286,270 @@ export const ApiContractV1Implementation = createNextRoute(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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
RecipientRole,
|
RecipientRole,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
SigningStatus,
|
SigningStatus,
|
||||||
|
TeamMemberRole,
|
||||||
TemplateType,
|
TemplateType,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
|
|
||||||
@@ -532,3 +533,41 @@ export const ZGetTemplatesQuerySchema = z.object({
|
|||||||
page: z.coerce.number().min(1).optional().default(1),
|
page: z.coerce.number().min(1).optional().default(1),
|
||||||
perPage: 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),
|
||||||
|
});
|
||||||
|
|||||||
278
packages/app-tests/e2e/api/v1/team-user-management.spec.ts
Normal file
278
packages/app-tests/e2e/api/v1/team-user-management.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ZFindTeamMembersResponseSchema,
|
||||||
|
ZSuccessfulInviteTeamMemberResponseSchema,
|
||||||
|
ZSuccessfulRemoveTeamMemberResponseSchema,
|
||||||
|
ZSuccessfulUpdateTeamMemberResponseSchema,
|
||||||
|
ZUnsuccessfulResponseSchema,
|
||||||
|
} from '@documenso/api/v1/schema';
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
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(`${WEBAPP_BASE_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(
|
||||||
|
`${WEBAPP_BASE_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(
|
||||||
|
`${WEBAPP_BASE_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(
|
||||||
|
`${WEBAPP_BASE_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(
|
||||||
|
`${WEBAPP_BASE_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(
|
||||||
|
`${WEBAPP_BASE_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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:dev": "playwright test",
|
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
|
||||||
"test-ui:dev": "playwright test --ui",
|
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
|
||||||
"test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
|
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const createApiToken = async ({
|
|||||||
name: tokenName,
|
name: tokenName,
|
||||||
token: hashedToken,
|
token: hashedToken,
|
||||||
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
|
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
|
||||||
userId: teamId ? null : userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOpt
|
|||||||
return await prisma.apiToken.delete({
|
return await prisma.apiToken.delete({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
userId: teamId ? null : userId,
|
teamId: teamId ?? null,
|
||||||
teamId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
|
|||||||
return await prisma.apiToken.findMany({
|
return await prisma.apiToken.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
|
teamId: null,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
|||||||
throw new Error('Expired token');
|
throw new Error('Expired token');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiToken.team) {
|
// Handle a silly choice from many moons ago
|
||||||
|
if (apiToken.team && !apiToken.user) {
|
||||||
apiToken.user = await prisma.user.findFirst({
|
apiToken.user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: apiToken.team.ownerUserId,
|
id: apiToken.team.ownerUserId,
|
||||||
@@ -33,9 +34,13 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
|||||||
|
|
||||||
const { user } = apiToken;
|
const { user } = apiToken;
|
||||||
|
|
||||||
|
// This will never happen but we need to narrow types
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('Invalid token');
|
throw new Error('Invalid token');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...apiToken, user };
|
return {
|
||||||
|
...apiToken,
|
||||||
|
user,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const seedTeam = async ({
|
|||||||
createMany: {
|
createMany: {
|
||||||
data: [teamOwner, ...teamMembers].map((user) => ({
|
data: [teamOwner, ...teamMembers].map((user) => ({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
role: TeamMemberRole.ADMIN,
|
role: user === teamOwner ? TeamMemberRole.ADMIN : TeamMemberRole.MEMBER,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user