From 0455c9461b6eb7e619998014e5cd28b650a2f5a8 Mon Sep 17 00:00:00 2001 From: Jackson Chen <541898146chen@gmail.com> Date: Sun, 27 Oct 2024 18:09:41 -0500 Subject: [PATCH] feat: Add project frontend API with tests and fix backend project API This commit adds the project frontend API along with its corresponding tests. It also fixes an issue in the backend project API where the user ID should be a number. Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- backend/src/app.module.ts | 23 ++- backend/src/app.resolver.ts | 12 ++ backend/src/app.service.ts | 8 - backend/src/auth/auth.service.ts | 247 ++++++++++++++++++++++- backend/src/auth/role/role.model.ts | 35 +++- backend/src/common/enums/role.enum.ts | 3 + backend/src/database.sqlite | Bin 86016 -> 94208 bytes backend/src/decorator/auth.decorator.ts | 47 +++++ backend/src/decorator/menu.decorator.ts | 4 + backend/src/decorator/roles.decorator.ts | 4 + backend/src/guard/menu.guard.ts | 70 +++++++ backend/src/guard/roles.guard.ts | 69 +++++++ backend/src/init/init-roles.service.ts | 56 +++++ backend/src/init/init.module.ts | 13 ++ backend/src/schema.gql | 3 +- backend/src/user/user.service.ts | 14 +- 16 files changed, 565 insertions(+), 43 deletions(-) create mode 100644 backend/src/app.resolver.ts delete mode 100644 backend/src/app.service.ts create mode 100644 backend/src/common/enums/role.enum.ts create mode 100644 backend/src/decorator/auth.decorator.ts create mode 100644 backend/src/decorator/menu.decorator.ts create mode 100644 backend/src/decorator/roles.decorator.ts create mode 100644 backend/src/guard/menu.guard.ts create mode 100644 backend/src/guard/roles.guard.ts create mode 100644 backend/src/init/init-roles.service.ts create mode 100644 backend/src/init/init.module.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4684cfa..711a60e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,18 +1,19 @@ +import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { Module } from '@nestjs/common'; -import { AppService } from './app.service'; +import { ConfigModule } from '@nestjs/config'; import { GraphQLModule } from '@nestjs/graphql'; -import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; -import { join } from 'path'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { UserModule } from './user/user.module'; -import { User } from './user/user.model'; +import { join } from 'path'; import { AuthModule } from './auth/auth.module'; +import { ChatModule } from './chat/chat.module'; import { ProjectModule } from './project/project.module'; import { TokenModule } from './token/token.module'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { JwtModule } from '@nestjs/jwt'; -import { JwtCacheService } from './auth/jwt-cache.service'; -import { ChatModule } from './chat/chat.module'; +import { UserModule } from './user/user.module'; +import { InitModule } from './init/init.module'; +import { RolesGuard } from './guard/roles.guard'; +import { MenuGuard } from './guard/menu.guard'; +import { User } from './user/user.model'; +import { AppResolver } from './app.resolver'; @Module({ imports: [ @@ -32,12 +33,14 @@ import { ChatModule } from './chat/chat.module'; logging: true, entities: [__dirname + '/**/*.model{.ts,.js}'], }), + InitModule, UserModule, AuthModule, ProjectModule, TokenModule, ChatModule, + TypeOrmModule.forFeature([User]), ], - providers: [AppService], + providers: [AppResolver], }) export class AppModule {} diff --git a/backend/src/app.resolver.ts b/backend/src/app.resolver.ts new file mode 100644 index 0000000..67f05b6 --- /dev/null +++ b/backend/src/app.resolver.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { Query, Resolver } from '@nestjs/graphql'; +import { RequireRoles } from './decorator/auth.decorator'; + +@Resolver() +export class AppResolver { + @Query(() => String) + @RequireRoles('Admin') + getHello(): string { + return 'Hello World!'; + } +} diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts deleted file mode 100644 index 927d7cc..0000000 --- a/backend/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index c9c1baa..0f54347 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -2,6 +2,7 @@ import { ConflictException, Injectable, Logger, + NotFoundException, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -11,9 +12,11 @@ import { compare, hash } from 'bcrypt'; import { LoginUserInput } from 'src/user/dto/login-user.input'; import { RegisterUserInput } from 'src/user/dto/register-user.input'; import { User } from 'src/user/user.model'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { CheckTokenInput } from './dto/check-token.input'; import { JwtCacheService } from 'src/auth/jwt-cache.service'; +import { Menu } from './menu/menu.model'; +import { Role } from './role/role.model'; @Injectable() export class AuthService { @@ -23,6 +26,10 @@ export class AuthService { private jwtService: JwtService, private jwtCacheService: JwtCacheService, private configService: ConfigService, + @InjectRepository(Menu) + private menuRepository: Repository, + @InjectRepository(Role) + private roleRepository: Repository, ) {} async register(registerUserInput: RegisterUserInput): Promise { @@ -95,4 +102,242 @@ export class AuthService { this.jwtCacheService.removeToken(token); return true; } + + async assignMenusToRole(roleId: string, menuIds: string[]): Promise { + // Find the role with existing menus + const role = await this.roleRepository.findOne({ + where: { id: roleId }, + relations: ['menus'], + }); + + if (!role) { + throw new NotFoundException(`Role with ID "${roleId}" not found`); + } + + // Find all menus + const menus = await this.menuRepository.findByIds(menuIds); + + if (menus.length !== menuIds.length) { + throw new NotFoundException('Some menus were not found'); + } + + if (!role.menus) { + role.menus = []; + } + + const newMenus = menus.filter( + (menu) => !role.menus.some((existingMenu) => existingMenu.id === menu.id), + ); + + if (newMenus.length === 0) { + throw new ConflictException( + 'All specified menus are already assigned to this role', + ); + } + + role.menus.push(...newMenus); + + try { + await this.roleRepository.save(role); + Logger.log( + `${newMenus.length} menus assigned to role ${role.name} successfully`, + ); + + return await this.roleRepository.findOne({ + where: { id: roleId }, + relations: ['menus'], + }); + } catch (error) { + Logger.error(`Failed to assign menus to role: ${error.message}`); + throw error; + } + } + + async removeMenuFromRole(roleId: string, menuId: string): Promise { + const role = await this.roleRepository.findOne({ + where: { id: roleId }, + relations: ['menus'], + }); + + if (!role) { + throw new NotFoundException(`Role with ID "${roleId}" not found`); + } + + const menuIndex = role.menus?.findIndex((menu) => menu.id === menuId); + + if (menuIndex === -1) { + throw new NotFoundException( + `Menu with ID "${menuId}" not found in role "${role.name}"`, + ); + } + + role.menus.splice(menuIndex, 1); + + try { + await this.roleRepository.save(role); + Logger.log(`Menu removed from role ${role.name} successfully`); + + return await this.roleRepository.findOne({ + where: { id: roleId }, + relations: ['menus'], + }); + } catch (error) { + Logger.error(`Failed to remove menu from role: ${error.message}`); + throw error; + } + } + + async assignRoles(userId: string, roleIds: string[]): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundException(`User with ID "${userId}" not found`); + } + + const roles = await this.roleRepository.findBy({ id: In(roleIds) }); + + if (roles.length !== roleIds.length) { + throw new NotFoundException('Some roles were not found'); + } + + if (!user.roles) { + user.roles = []; + } + + const newRoles = roles.filter( + (role) => !user.roles.some((existingRole) => existingRole.id === role.id), + ); + + if (newRoles.length === 0) { + throw new ConflictException( + 'All specified roles are already assigned to this user', + ); + } + + user.roles.push(...newRoles); + + try { + await this.userRepository.save(user); + Logger.log( + `${newRoles.length} roles assigned to user ${user.username} successfully`, + ); + + return await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + } catch (error) { + Logger.error(`Failed to assign roles to user: ${error.message}`); + throw error; + } + } + + async removeRoleFromUser(userId: string, roleId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundException(`User with ID "${userId}" not found`); + } + + const roleIndex = user.roles?.findIndex((role) => role.id === roleId); + + if (roleIndex === -1) { + throw new NotFoundException( + `Role with ID "${roleId}" not found in user's roles`, + ); + } + + user.roles.splice(roleIndex, 1); + + try { + await this.userRepository.save(user); + Logger.log(`Role removed from user ${user.username} successfully`); + + return await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + } catch (error) { + Logger.error(`Failed to remove role from user: ${error.message}`); + throw error; + } + } + + async getUserRolesAndMenus(userId: string): Promise<{ + roles: Role[]; + menus: Menu[]; + }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles', 'roles.menus'], + }); + + if (!user) { + throw new NotFoundException(`User with ID "${userId}" not found`); + } + + const userRoles = user.roles || []; + + // Get unique menus across all roles + const userMenus = Array.from( + new Map( + userRoles + .flatMap((role) => role.menus || []) + .map((menu) => [menu.id, menu]), + ).values(), + ); + + return { + roles: userRoles, + menus: userMenus, + }; + } + + async getUserRoles(userId: string): Promise<{ + roles: Role[]; + }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundException(`User with ID "${userId}" not found`); + } + + return { + roles: user.roles || [], + }; + } + + async getUserMenus(userId: string): Promise<{ + menus: Menu[]; + }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles', 'roles.menus'], + }); + + if (!user) { + throw new NotFoundException(`User with ID "${userId}" not found`); + } + + const userMenus = Array.from( + new Map( + (user.roles || []) + .flatMap((role) => role.menus || []) + .map((menu) => [menu.id, menu]), + ).values(), + ); + + return { + menus: userMenus, + }; + } } diff --git a/backend/src/auth/role/role.model.ts b/backend/src/auth/role/role.model.ts index b3ff71a..8c98212 100644 --- a/backend/src/auth/role/role.model.ts +++ b/backend/src/auth/role/role.model.ts @@ -10,21 +10,23 @@ import { } from 'typeorm'; import { Menu } from '../menu/menu.model'; -@Entity() @ObjectType() -export class Role extends SystemBaseModel { - @Field(() => ID) - @PrimaryGeneratedColumn() +@Entity() +export class Role { + @Field() + @PrimaryGeneratedColumn('uuid') id: string; @Field() - @Column() + @Column({ unique: true }) name: string; - @ManyToMany(() => User, (user) => user.roles) - users: User[]; + @Field({ nullable: true }) + @Column({ nullable: true }) + description?: string; - @ManyToMany(() => Menu) + @Field(() => [Menu], { nullable: true }) + @ManyToMany(() => Menu, { eager: true }) @JoinTable({ name: 'role_menus', joinColumn: { @@ -36,6 +38,19 @@ export class Role extends SystemBaseModel { referencedColumnName: 'id', }, }) - @Field(() => [Menu]) - menus: Menu[]; + menus?: Menu[]; + + @ManyToMany(() => User, (user) => user.roles) + @JoinTable({ + name: 'user_roles', + joinColumn: { + name: 'role_id', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'user_id', + referencedColumnName: 'id', + }, + }) + users?: User[]; } diff --git a/backend/src/common/enums/role.enum.ts b/backend/src/common/enums/role.enum.ts new file mode 100644 index 0000000..ef7deb9 --- /dev/null +++ b/backend/src/common/enums/role.enum.ts @@ -0,0 +1,3 @@ +export enum DefaultRoles { + ADMIN = 'Admin', +} diff --git a/backend/src/database.sqlite b/backend/src/database.sqlite index 133380fea8371a7ae975f5daebdebd054142bee8..05a825f5428826fe0416ffcd352d21fa37ca8296 100644 GIT binary patch delta 3529 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 diff --git a/backend/src/decorator/auth.decorator.ts b/backend/src/decorator/auth.decorator.ts new file mode 100644 index 0000000..944f360 --- /dev/null +++ b/backend/src/decorator/auth.decorator.ts @@ -0,0 +1,47 @@ +// This is combination of RolesGuard, MenuGuard, roles.decorator.ts, menu.decorator.ts, auth.decorator.ts +// Example usage: +// @Query(() => [Project]) +// @RequireRoles('Admin', 'Manager') +// @RequireMenu('/project/create') +// @RequireAuth({ +// roles: ['Admin'], +// menuPath: '/project/detail' +// }) +// async getProjects() { +// return this.projectService.findAll(); +// } +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { Roles } from './roles.decorator'; +import { Menu } from './menu.decorator'; +import { RolesGuard } from 'src/guard/roles.guard'; +import { MenuGuard } from 'src/guard/menu.guard'; + +export function Auth() { + return applyDecorators(UseGuards(RolesGuard, MenuGuard)); +} + +export function RequireRoles(...roles: string[]) { + return applyDecorators(Roles(...roles), UseGuards(RolesGuard)); +} + +export function RequireMenu(path: string) { + return applyDecorators(Menu(path), UseGuards(MenuGuard)); +} + +export function RequireAuth(options?: { roles?: string[]; menuPath?: string }) { + if (!options) { + return Auth(); + } + + const decorators = [Auth()]; + + if (options.roles?.length) { + decorators.push(Roles(...options.roles)); + } + + if (options.menuPath) { + decorators.push(Menu(options.menuPath)); + } + + return applyDecorators(...decorators); +} diff --git a/backend/src/decorator/menu.decorator.ts b/backend/src/decorator/menu.decorator.ts new file mode 100644 index 0000000..7ca702a --- /dev/null +++ b/backend/src/decorator/menu.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const MENU_PATH_KEY = 'menuPath'; +export const Menu = (path: string) => SetMetadata(MENU_PATH_KEY, path); diff --git a/backend/src/decorator/roles.decorator.ts b/backend/src/decorator/roles.decorator.ts new file mode 100644 index 0000000..e038e16 --- /dev/null +++ b/backend/src/decorator/roles.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/backend/src/guard/menu.guard.ts b/backend/src/guard/menu.guard.ts new file mode 100644 index 0000000..48db5c4 --- /dev/null +++ b/backend/src/guard/menu.guard.ts @@ -0,0 +1,70 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { InjectRepository } from '@nestjs/typeorm'; +import { MENU_PATH_KEY } from 'src/decorator/menu.decorator'; +import { User } from 'src/user/user.model'; +import { Repository } from 'typeorm'; +@Injectable() +export class MenuGuard implements CanActivate { + private readonly logger = new Logger(MenuGuard.name); + + constructor( + private reflector: Reflector, + @InjectRepository(User) + private userRepository: Repository, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredPath = this.reflector.getAllAndOverride( + MENU_PATH_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredPath) { + return true; + } + + const gqlContext = GqlExecutionContext.create(context); + const { req } = gqlContext.getContext(); + + if (!req.user?.id) { + throw new UnauthorizedException('User is not authenticated'); + } + + try { + const user = await this.userRepository.findOne({ + where: { id: req.user.id }, + relations: ['roles', 'roles.menus'], + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + const hasMenuAccess = user.roles.some((role) => + role.menus?.some((menu) => menu.path === requiredPath), + ); + + if (!hasMenuAccess) { + this.logger.warn( + `User ${user.username} attempted to access menu path: ${requiredPath} without permission`, + ); + throw new UnauthorizedException( + 'User does not have access to this menu', + ); + } + + return true; + } catch (error) { + this.logger.error(`Menu access check failed: ${error.message}`); + throw error; + } + } +} diff --git a/backend/src/guard/roles.guard.ts b/backend/src/guard/roles.guard.ts new file mode 100644 index 0000000..5400e43 --- /dev/null +++ b/backend/src/guard/roles.guard.ts @@ -0,0 +1,69 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ROLES_KEY } from 'src/decorator/roles.decorator'; +import { User } from 'src/user/user.model'; +import { Repository } from 'typeorm'; + +@Injectable() +export class RolesGuard implements CanActivate { + private readonly logger = new Logger(RolesGuard.name); + + constructor( + private reflector: Reflector, + @InjectRepository(User) + private userRepository: Repository, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredRoles) { + return true; + } + + const gqlContext = GqlExecutionContext.create(context); + const { req } = gqlContext.getContext(); + + if (!req.user?.id) { + throw new UnauthorizedException('User is not authenticated'); + } + + try { + const user = await this.userRepository.findOne({ + where: { id: req.user.id }, + relations: ['roles'], + }); + + if (!user) { + throw new UnauthorizedException('User not found'); + } + + const hasRole = user.roles.some((role) => + requiredRoles.includes(role.name), + ); + + if (!hasRole) { + this.logger.warn( + `User ${user.username} attempted to access resource without required roles: ${requiredRoles.join(', ')}`, + ); + throw new UnauthorizedException('User does not have required roles'); + } + + return true; + } catch (error) { + this.logger.error(`Role check failed: ${error.message}`); + throw error; + } + } +} diff --git a/backend/src/init/init-roles.service.ts b/backend/src/init/init-roles.service.ts new file mode 100644 index 0000000..785523e --- /dev/null +++ b/backend/src/init/init-roles.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Role } from '../auth/role/role.model'; +import { DefaultRoles } from '../common/enums/role.enum'; + +@Injectable() +export class InitRolesService implements OnApplicationBootstrap { + private readonly logger = new Logger(InitRolesService.name); + + constructor( + @InjectRepository(Role) + private roleRepository: Repository, + ) {} + + async onApplicationBootstrap() { + await this.initializeDefaultRoles(); + } + + private async initializeDefaultRoles() { + this.logger.log('Checking and initializing default roles...'); + + const defaultRoles = Object.values(DefaultRoles); + + for (const roleName of defaultRoles) { + const existingRole = await this.roleRepository.findOne({ + where: { name: roleName }, + }); + + if (!existingRole) { + await this.createDefaultRole(roleName); + } + } + + this.logger.log('Default roles initialization completed'); + } + + private async createDefaultRole(roleName: string) { + try { + const newRole = this.roleRepository.create({ + name: roleName, + description: `Default ${roleName} role`, + menus: [], + }); + + await this.roleRepository.save(newRole); + this.logger.log(`Created default role: ${roleName}`); + } catch (error) { + this.logger.error( + `Failed to create default role ${roleName}:`, + error.message, + ); + throw error; + } + } +} diff --git a/backend/src/init/init.module.ts b/backend/src/init/init.module.ts new file mode 100644 index 0000000..3f9e131 --- /dev/null +++ b/backend/src/init/init.module.ts @@ -0,0 +1,13 @@ +// This module is use for init operation like init roles, init permissions, init users, etc. +// This module is imported in app.module.ts +// @Author: Jackson Chen +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Role } from '../auth/role/role.model'; +import { InitRolesService } from './init-roles.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Role])], + providers: [InitRolesService], +}) +export class InitModule {} diff --git a/backend/src/schema.gql b/backend/src/schema.gql index e6570c2..7f40064 100644 --- a/backend/src/schema.gql +++ b/backend/src/schema.gql @@ -66,7 +66,7 @@ type Project { id: ID! isActive: Boolean! isDeleted: Boolean! - path: String + path: String! projectName: String! projectPackages: [ProjectPackages!] updatedAt: Date! @@ -85,6 +85,7 @@ type ProjectPackages { type Query { checkToken(input: CheckTokenInput!): Boolean! + getHello: String! getProjectDetails(projectId: String!): Project! getUserProjects: [Project!]! logout: Boolean! diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index d5aa378..668a7d6 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -1,16 +1,4 @@ -import { - ConflictException, - Injectable, - UnauthorizedException, -} from '@nestjs/common'; -import { RegisterUserInput } from './dto/register-user.input'; -import { User } from './user.model'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { compare, hash } from 'bcrypt'; -import { JwtService } from '@nestjs/jwt'; -import { LoginUserInput } from './dto/login-user.input'; -import { ConfigService } from '@nestjs/config'; +import { Injectable } from '@nestjs/common'; @Injectable() export class UserService {}