diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 3c485ca8..89314ecf 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -14,7 +14,7 @@ import { JwtStrategy } from "./jwt.strategy"; useFactory: async (configService: ConfigService) => { return { signOptions: { expiresIn: "24h" }, - secret: configService.get("JWT_SECRET"), + secret: configService.get("JWT_AUTH_SECRET"), }; }, inject: [ConfigService], diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index 60dfc31d..7c823730 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -11,7 +11,7 @@ export class JwtStrategy extends PassportStrategy(PassportJwtStrategy) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get("JWT_SECRET"), + secretOrKey: configService.get("JWT_AUTH_SECRET"), }); } diff --git a/backend/src/utils/constants/auth-role.ts b/backend/src/utils/constants/auth-role.ts new file mode 100644 index 00000000..f7f4ec65 --- /dev/null +++ b/backend/src/utils/constants/auth-role.ts @@ -0,0 +1,4 @@ +export const WorkspaceRoleConstants = { + OWNER: "OWNER", + MEMBER: "MEMBER", +}; diff --git a/backend/src/workspaces/dto/CreateWorkspace.dto.ts b/backend/src/workspaces/dto/create-workspace.dto.ts similarity index 100% rename from backend/src/workspaces/dto/CreateWorkspace.dto.ts rename to backend/src/workspaces/dto/create-workspace.dto.ts diff --git a/backend/src/workspaces/dto/join-workspace.dto.ts b/backend/src/workspaces/dto/join-workspace.dto.ts new file mode 100644 index 00000000..c970404b --- /dev/null +++ b/backend/src/workspaces/dto/join-workspace.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class JoinWorkspaceDto { + @ApiProperty({ description: "Invitation token of workspace to join", type: String }) + invitationToken: string; +} diff --git a/backend/src/workspaces/types/create-inviation-token-response.type.ts b/backend/src/workspaces/types/create-inviation-token-response.type.ts new file mode 100644 index 00000000..98ef6b17 --- /dev/null +++ b/backend/src/workspaces/types/create-inviation-token-response.type.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export class CreateInvitationTokenResponse { + @ApiProperty({ type: String, description: "Token for invitation" }) + invitationToken: string; +} diff --git a/backend/src/workspaces/types/inviation-token-payload.type.ts b/backend/src/workspaces/types/inviation-token-payload.type.ts new file mode 100644 index 00000000..90f44d0f --- /dev/null +++ b/backend/src/workspaces/types/inviation-token-payload.type.ts @@ -0,0 +1,4 @@ +export class InvitationTokenPayload { + sub: string; + workspaceId: string; +} diff --git a/backend/src/workspaces/types/join-workspace-response.type.ts b/backend/src/workspaces/types/join-workspace-response.type.ts new file mode 100644 index 00000000..447e8843 --- /dev/null +++ b/backend/src/workspaces/types/join-workspace-response.type.ts @@ -0,0 +1,3 @@ +import { WorkspaceDomain } from "./workspace-domain.type"; + +export class JoinWorkspaceResponse extends WorkspaceDomain {} diff --git a/backend/src/workspaces/workspaces.controller.ts b/backend/src/workspaces/workspaces.controller.ts index ee1f94ba..04784e6f 100644 --- a/backend/src/workspaces/workspaces.controller.ts +++ b/backend/src/workspaces/workspaces.controller.ts @@ -10,22 +10,28 @@ import { Req, } from "@nestjs/common"; import { WorkspacesService } from "./workspaces.service"; -import { CreateWorkspaceDto } from "./dto/CreateWorkspace.dto"; +import { CreateWorkspaceDto } from "./dto/create-workspace.dto"; import { ApiBearerAuth, ApiBody, ApiCreatedResponse, ApiFoundResponse, ApiNotFoundResponse, + ApiOkResponse, ApiOperation, + ApiParam, ApiQuery, ApiTags, + ApiUnauthorizedResponse, } from "@nestjs/swagger"; import { AuthroizedRequest } from "src/utils/types/req.type"; import { CreateWorkspaceResponse } from "./types/create-workspace-response.type"; import { FindWorkspaceResponse } from "./types/find-workspace-response.type"; import { HttpExceptionResponse } from "src/utils/types/http-exception-response.type"; import { FindWorkspacesResponse } from "./types/find-workspaces-response.type"; +import { CreateInvitationTokenResponse } from "./types/create-inviation-token-response.type"; +import { JoinWorkspaceDto } from "./dto/join-workspace.dto"; +import { JoinWorkspaceResponse } from "./types/join-workspace-response.type"; @ApiTags("Workspaces") @ApiBearerAuth() @@ -90,4 +96,50 @@ export class WorkspacesController { ): Promise { return this.workspacesService.findMany(req.user.id, pageSize, cursor); } + + @Post(":workspace_id/invite-token") + @ApiOperation({ + summary: "Create a Invitation Token", + description: "Create a inviation token using JWT.", + }) + @ApiParam({ + name: "workspace_id", + description: "ID of workspace to create invitation token", + }) + @ApiOkResponse({ + type: CreateInvitationTokenResponse, + }) + @ApiNotFoundResponse({ + type: HttpExceptionResponse, + description: "The workspace does not exist, or the user lacks the appropriate permissions.", + }) + async createInvitationToken( + @Req() req: AuthroizedRequest, + @Param("workspace_id") workspaceId: string + ): Promise { + return this.workspacesService.createInvitationToken(req.user.id, workspaceId); + } + + @Post("join") + @ApiOperation({ + summary: "Join to the Workspace", + description: "Join to the workspace using JWT invitation token.", + }) + @ApiOkResponse({ + type: JoinWorkspaceResponse, + }) + @ApiUnauthorizedResponse({ + type: HttpExceptionResponse, + description: "Invitation token is invalid or expired.", + }) + @ApiNotFoundResponse({ + description: "The workspace does not exist.", + type: HttpExceptionResponse, + }) + async join( + @Req() req: AuthroizedRequest, + @Body() joinWorkspaceDto: JoinWorkspaceDto + ): Promise { + return this.workspacesService.join(req.user.id, joinWorkspaceDto.invitationToken); + } } diff --git a/backend/src/workspaces/workspaces.module.ts b/backend/src/workspaces/workspaces.module.ts index ed01ef3d..28dded52 100644 --- a/backend/src/workspaces/workspaces.module.ts +++ b/backend/src/workspaces/workspaces.module.ts @@ -2,8 +2,21 @@ import { Module } from "@nestjs/common"; import { WorkspacesController } from "./workspaces.controller"; import { WorkspacesService } from "./workspaces.service"; import { PrismaService } from "src/db/prisma.service"; +import { JwtModule } from "@nestjs/jwt"; +import { ConfigService } from "@nestjs/config"; @Module({ + imports: [ + JwtModule.registerAsync({ + useFactory: async (configService: ConfigService) => { + return { + signOptions: { expiresIn: "12h" }, + secret: configService.get("JWT_INVITATION_SECRET"), + }; + }, + inject: [ConfigService], + }), + ], controllers: [WorkspacesController], providers: [WorkspacesService, PrismaService], }) diff --git a/backend/src/workspaces/workspaces.service.ts b/backend/src/workspaces/workspaces.service.ts index ab6c662d..33cec48e 100644 --- a/backend/src/workspaces/workspaces.service.ts +++ b/backend/src/workspaces/workspaces.service.ts @@ -1,11 +1,18 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"; import { Prisma, Workspace } from "@prisma/client"; import { PrismaService } from "src/db/prisma.service"; import { FindWorkspacesResponse } from "./types/find-workspaces-response.type"; +import { JwtService } from "@nestjs/jwt"; +import { CreateInvitationTokenResponse } from "./types/create-inviation-token-response.type"; +import { InvitationTokenPayload } from "./types/inviation-token-payload.type"; +import { WorkspaceRoleConstants } from "src/utils/constants/auth-role"; @Injectable() export class WorkspacesService { - constructor(private prismaService: PrismaService) {} + constructor( + private prismaService: PrismaService, + private jwtService: JwtService + ) {} async create(userId: string, title: string): Promise { const workspace = await this.prismaService.workspace.create({ @@ -18,7 +25,7 @@ export class WorkspacesService { data: { workspaceId: workspace.id, userId, - role: "OWNER", + role: WorkspaceRoleConstants.OWNER, }, }); @@ -77,4 +84,78 @@ export class WorkspacesService { cursor: workspaceList.length > pageSize ? workspaceList[pageSize].id : null, }; } + + async createInvitationToken( + userId: string, + workspaceId: string + ): Promise { + try { + await this.prismaService.userWorkspace.findFirstOrThrow({ + where: { + userId, + workspaceId, + }, + }); + } catch (e) { + throw new NotFoundException(); + } + + const invitationToken = this.jwtService.sign({ + sub: userId, + workspaceId, + }); + + return { + invitationToken, + }; + } + + async join(userId: string, invitationToken: string) { + let workspaceId: string; + + try { + const payload = this.jwtService.verify(invitationToken); + + workspaceId = payload.workspaceId; + } catch (err) { + throw new UnauthorizedException("Invitation token is invalid or expired."); + } + + try { + await this.prismaService.workspace.findUniqueOrThrow({ + where: { + id: workspaceId, + }, + }); + } catch (e) { + throw new NotFoundException("The workspace is deleted."); + } + + const userWorkspace = await this.prismaService.userWorkspace.findFirst({ + where: { + userId, + workspaceId, + }, + include: { + workspace: true, + }, + }); + + if (!userWorkspace) { + return userWorkspace.workspace; + } + + const newUserWorkspace = await this.prismaService.userWorkspace.create({ + data: { + userId, + workspaceId, + role: WorkspaceRoleConstants.MEMBER, + }, + include: { + workspace: true, + }, + }); + + return newUserWorkspace.workspace; + } }