diff --git a/apps/server/src/modules/auth/index.ts b/apps/server/src/modules/auth/index.ts index d628076082..df99537183 100644 --- a/apps/server/src/modules/auth/index.ts +++ b/apps/server/src/modules/auth/index.ts @@ -11,4 +11,6 @@ import { AuthService } from './service'; controllers: [NextAuthController], }) export class AuthModule {} + export * from './guard'; +export { TokenType } from './resolver'; diff --git a/apps/server/src/modules/auth/resolver.ts b/apps/server/src/modules/auth/resolver.ts index 1d63cdfdb2..909b0782b2 100644 --- a/apps/server/src/modules/auth/resolver.ts +++ b/apps/server/src/modules/auth/resolver.ts @@ -40,6 +40,18 @@ export class AuthResolver { }; } + @Mutation(() => UserType) + async register( + @Context() ctx: { req: Request }, + @Args('name') name: string, + @Args('email') email: string, + @Args('password') password: string + ) { + const user = await this.auth.register(name, email, password); + ctx.req.user = user; + return user; + } + @Mutation(() => UserType) async signIn( @Context() ctx: { req: Request }, diff --git a/apps/server/src/modules/users/index.ts b/apps/server/src/modules/users/index.ts index da307d037f..c885a06534 100644 --- a/apps/server/src/modules/users/index.ts +++ b/apps/server/src/modules/users/index.ts @@ -8,3 +8,5 @@ import { UserResolver } from './resolver'; providers: [UserResolver], }) export class UsersModule {} + +export { UserType } from './resolver'; diff --git a/apps/server/src/modules/workspaces/permission.ts b/apps/server/src/modules/workspaces/permission.ts index 5a11e4013d..4b9825fb98 100644 --- a/apps/server/src/modules/workspaces/permission.ts +++ b/apps/server/src/modules/workspaces/permission.ts @@ -117,6 +117,21 @@ export class PermissionService { }); } + async accept(ws: string, user: string) { + const result = await this.prisma.userWorkspacePermission.updateMany({ + where: { + workspaceId: ws, + userId: user, + accepted: false, + }, + data: { + accepted: true, + }, + }); + + return result.count > 0; + } + async revoke(ws: string, user: string) { const result = await this.prisma.userWorkspacePermission.deleteMany({ where: { diff --git a/apps/server/src/modules/workspaces/resolver.ts b/apps/server/src/modules/workspaces/resolver.ts index 70df939144..8f0094082b 100644 --- a/apps/server/src/modules/workspaces/resolver.ts +++ b/apps/server/src/modules/workspaces/resolver.ts @@ -111,6 +111,29 @@ export class WorkspaceResolver { return data.user; } + @ResolveField(() => [UserType], { + description: 'Members of workspace', + complexity: 2, + }) + async members( + @CurrentUser() user: UserType, + @Parent() workspace: WorkspaceType + ) { + const data = await this.prisma.userWorkspacePermission.findMany({ + where: { + workspaceId: workspace.id, + accepted: true, + userId: { + not: user.id, + }, + }, + include: { + user: true, + }, + }); + return data.map(({ user }) => user); + } + @Query(() => [WorkspaceType], { description: 'Get all accessible workspaces for current user', complexity: 2, @@ -203,4 +226,61 @@ export class WorkspaceResolver { return true; } + + @Mutation(() => Boolean) + async invite( + @CurrentUser() user: User, + @Args('workspaceId') workspaceId: string, + @Args('email') email: string, + @Args('permission', { type: () => Permission }) permission: Permission + ) { + await this.permissionProvider.check(workspaceId, user.id, Permission.Admin); + + if (permission === Permission.Owner) { + throw new ForbiddenException('Cannot change owner'); + } + + const target = await this.prisma.user.findUnique({ + where: { + email, + }, + }); + + if (!target) { + throw new NotFoundException("User doesn't exist"); + } + + await this.permissionProvider.grant(workspaceId, target.id, permission); + + return true; + } + + @Mutation(() => Boolean) + async revoke( + @CurrentUser() user: User, + @Args('workspaceId') workspaceId: string, + @Args('userId') userId: string + ) { + await this.permissionProvider.check(workspaceId, user.id, Permission.Admin); + + return this.permissionProvider.revoke(workspaceId, userId); + } + + @Mutation(() => Boolean) + async acceptInvite( + @CurrentUser() user: User, + @Args('workspaceId') workspaceId: string + ) { + return this.permissionProvider.accept(workspaceId, user.id); + } + + @Mutation(() => Boolean) + async leaveWorkspace( + @CurrentUser() user: User, + @Args('workspaceId') workspaceId: string + ) { + await this.permissionProvider.check(workspaceId, user.id); + + return this.permissionProvider.revoke(workspaceId, user.id); + } } diff --git a/apps/server/src/schema.gql b/apps/server/src/schema.gql index 5ccc1b20bd..791ef52c1a 100644 --- a/apps/server/src/schema.gql +++ b/apps/server/src/schema.gql @@ -64,6 +64,11 @@ type WorkspaceType { Owner of workspace """ owner: UserType! + + """ + Members of workspace + """ + members: [UserType!]! } """ @@ -94,6 +99,7 @@ type Query { } type Mutation { + register(name: String!, email: String!, password: String!): UserType! signIn(email: String!, password: String!): UserType! signUp(email: String!, password: String!, name: String!): UserType! @@ -107,6 +113,14 @@ type Mutation { """ updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType! deleteWorkspace(id: String!): Boolean! + invite( + workspaceId: String! + email: String! + permission: Permission! + ): Boolean! + revoke(workspaceId: String!, userId: String!): Boolean! + acceptInvite(workspaceId: String!): Boolean! + leaveWorkspace(workspaceId: String!): Boolean! """ Upload user avatar diff --git a/apps/server/src/tests/workspace.spec.ts b/apps/server/src/tests/workspace.spec.ts new file mode 100644 index 0000000000..ac593cd1ee --- /dev/null +++ b/apps/server/src/tests/workspace.spec.ts @@ -0,0 +1,216 @@ +import { ok } from 'node:assert'; +import { afterEach, beforeEach, describe, test } from 'node:test'; + +import type { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; + +import { AppModule } from '../app'; +import { getDefaultAFFiNEConfig } from '../config/default'; +import type { TokenType } from '../modules/auth'; +import type { UserType } from '../modules/users'; +import type { WorkspaceType } from '../modules/workspaces'; + +const gql = '/graphql'; + +globalThis.AFFiNE = getDefaultAFFiNEConfig(); + +describe('AppModule', () => { + let app: INestApplication; + + // cleanup database before each test + beforeEach(async () => { + const client = new PrismaClient(); + await client.$connect(); + await client.user.deleteMany({}); + await client.$disconnect(); + }); + + beforeEach(async () => { + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + app = module.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + async function registerUser( + name: string, + email: string, + password: string + ): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .send({ + query: ` + mutation { + register(name: "${name}", email: "${email}", password: "${password}") { + id, name, email, token { token } + } + } + `, + }) + .expect(200); + return res.body.data.register; + } + + async function createWorkspace(token: string): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: ` + mutation { + createWorkspace { + id + } + } + `, + }) + .expect(200); + return res.body.data.createWorkspace; + } + + async function inviteUser( + token: string, + workspaceId: string, + email: string, + permission: string + ): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: ` + mutation { + invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission}) + } + `, + }) + .expect(200); + return res.body.data.invite; + } + + async function acceptInvite( + token: string, + workspaceId: string + ): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: ` + mutation { + acceptInvite(workspaceId: "${workspaceId}") + } + `, + }) + .expect(200); + return res.body.data.acceptInvite; + } + + async function leaveWorkspace( + token: string, + workspaceId: string + ): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: ` + mutation { + leaveWorkspace(workspaceId: "${workspaceId}") + } + `, + }) + .expect(200); + return res.body.data.leaveWorkspace; + } + + async function revokeUser( + token: string, + workspaceId: string, + userId: string + ): Promise { + const res = await request(app.getHttpServer()) + .post(gql) + .auth(token, { type: 'bearer' }) + .send({ + query: ` + mutation { + revoke(workspaceId: "${workspaceId}", userId: "${userId}") + } + `, + }) + .expect(200); + return res.body.data.revoke; + } + + test('should register a user', async () => { + const user = await registerUser('u1', 'u1@affine.pro', '123456'); + ok(typeof user.id === 'string', 'user.id is not a string'); + ok(user.name === 'u1', 'user.name is not valid'); + ok(user.email === 'u1@affine.pro', 'user.email is not valid'); + }); + + test('should create a workspace', async () => { + const user = await registerUser('u1', 'u1@affine.pro', '1'); + + const workspace = await createWorkspace(user.token.token); + ok(typeof workspace.id === 'string', 'workspace.id is not a string'); + }); + + test('should invite a user', async () => { + const u1 = await registerUser('u1', 'u1@affine.pro', '1'); + const u2 = await registerUser('u2', 'u2@affine.pro', '1'); + + const workspace = await createWorkspace(u1.token.token); + + const invite = await inviteUser( + u1.token.token, + workspace.id, + u2.email, + 'Admin' + ); + ok(invite === true, 'failed to invite user'); + }); + + test('should accept an invite', async () => { + const u1 = await registerUser('u1', 'u1@affine.pro', '1'); + const u2 = await registerUser('u2', 'u2@affine.pro', '1'); + + const workspace = await createWorkspace(u1.token.token); + await inviteUser(u1.token.token, workspace.id, u2.email, 'Admin'); + + const accept = await acceptInvite(u2.token.token, workspace.id); + ok(accept === true, 'failed to accept invite'); + }); + + test('should leave a workspace', async () => { + const u1 = await registerUser('u1', 'u1@affine.pro', '1'); + const u2 = await registerUser('u2', 'u2@affine.pro', '1'); + + const workspace = await createWorkspace(u1.token.token); + await inviteUser(u1.token.token, workspace.id, u2.email, 'Admin'); + await acceptInvite(u2.token.token, workspace.id); + + const leave = await leaveWorkspace(u2.token.token, workspace.id); + ok(leave === true, 'failed to leave workspace'); + }); + + test('should revoke a user', async () => { + const u1 = await registerUser('u1', 'u1@affine.pro', '1'); + const u2 = await registerUser('u2', 'u2@affine.pro', '1'); + + const workspace = await createWorkspace(u1.token.token); + await inviteUser(u1.token.token, workspace.id, u2.email, 'Admin'); + + const revoke = await revokeUser(u1.token.token, workspace.id, u2.id); + ok(revoke === true, 'failed to revoke user'); + }); +});