From 450bdc772c8e0788a556a4462a32b70edaf7fde7 Mon Sep 17 00:00:00 2001 From: Jackson Chen <90215880+Sma1lboy@users.noreply.github.com> Date: Sun, 27 Oct 2024 18:29:54 -0500 Subject: [PATCH] feat: Add project frontend API with tests and fix backend project API (#25) #5 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. ## Summary by CodeRabbit ## Release Notes - **New Features** - Introduced a new `getHello` GraphQL query for a simple greeting response. - Added role and menu management functionalities in the `AuthService`. - Implemented `MenuGuard` and `RolesGuard` for enhanced access control. - Created an `InitRolesService` for initializing default roles on application startup. - **Bug Fixes** - Improved formatting in the `README.md` for better readability. - **Documentation** - Updated GraphQL schema to reflect new query and field requirements. - **Chores** - Removed unused imports in the `UserService`. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- README.md | 2 +- 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 +- 17 files changed, 566 insertions(+), 44 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/README.md b/README.md index 2cb6692..88141f9 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,4 @@ Still on progress CodeFox LOGO -![](./assets/WechatIMG1000.svg) \ No newline at end of file +![](./assets/WechatIMG1000.svg) 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 {}