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 133380f..05a825f 100644 Binary files a/backend/src/database.sqlite and b/backend/src/database.sqlite differ 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 {}