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:
Lucas Smith
2024-10-22 22:53:31 +11:00
committed by GitHub
parent 514edf01d3
commit eafd7c551b
10 changed files with 666 additions and 10 deletions

View File

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

View File

@ -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 { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
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 { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
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';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
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 { 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,
},
};
}),
});

View File

@ -19,6 +19,7 @@ import {
RecipientRole,
SendStatus,
SigningStatus,
TeamMemberRole,
TemplateType,
} from '@documenso/prisma/client';
@ -532,3 +533,41 @@ export const ZGetTemplatesQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
});
export const ZFindTeamMembersResponseSchema = z.object({
members: z.array(
z.object({
id: z.number(),
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
}),
),
});
export const ZInviteTeamMemberMutationSchema = z.object({
email: z
.string()
.email()
.transform((email) => email.toLowerCase()),
role: z.nativeEnum(TeamMemberRole).optional().default(TeamMemberRole.MEMBER),
});
export const ZSuccessfulInviteTeamMemberResponseSchema = z.object({
message: z.string(),
});
export const ZUpdateTeamMemberMutationSchema = z.object({
role: z.nativeEnum(TeamMemberRole),
});
export const ZSuccessfulUpdateTeamMemberResponseSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
});
export const ZSuccessfulRemoveTeamMemberResponseSchema = z.object({
id: z.number(),
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
});

View File

@ -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();
});
});

View File

@ -5,9 +5,9 @@
"description": "",
"main": "index.js",
"scripts": {
"test:dev": "playwright test",
"test-ui:dev": "playwright test --ui",
"test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
},
"keywords": [],
"author": "",

View File

@ -51,7 +51,7 @@ export const createApiToken = async ({
name: tokenName,
token: hashedToken,
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
userId: teamId ? null : userId,
userId,
teamId,
},
});

View File

@ -25,8 +25,7 @@ export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOpt
return await prisma.apiToken.delete({
where: {
id,
userId: teamId ? null : userId,
teamId,
teamId: teamId ?? null,
},
});
};

View File

@ -8,6 +8,7 @@ export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
return await prisma.apiToken.findMany({
where: {
userId,
teamId: null,
},
select: {
id: true,

View File

@ -23,7 +23,8 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
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({
where: {
id: apiToken.team.ownerUserId,
@ -33,9 +34,13 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
const { user } = apiToken;
// This will never happen but we need to narrow types
if (!user) {
throw new Error('Invalid token');
}
return { ...apiToken, user };
return {
...apiToken,
user,
};
};

View File

@ -42,7 +42,7 @@ export const seedTeam = async ({
createMany: {
data: [teamOwner, ...teamMembers].map((user) => ({
userId: user.id,
role: TeamMemberRole.ADMIN,
role: user === teamOwner ? TeamMemberRole.ADMIN : TeamMemberRole.MEMBER,
})),
},
},