├── .gitignore ├── .prettierrc ├── apps ├── api │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── nest-cli.json │ ├── package.json │ ├── prisma │ │ ├── dev.db │ │ ├── migrations │ │ │ ├── 20230221190122_init │ │ │ │ └── migration.sql │ │ │ └── migration_lock.toml │ │ ├── schema.prisma │ │ └── seed.ts │ ├── src │ │ ├── app.module.ts │ │ ├── auth │ │ │ ├── auth.controller.ts │ │ │ ├── auth.module.ts │ │ │ ├── auth.service.ts │ │ │ ├── enums │ │ │ │ └── index.ts │ │ │ ├── guards │ │ │ │ ├── index.ts │ │ │ │ ├── roles.guard.ts │ │ │ │ └── session.guard.ts │ │ │ ├── rbac-policy.ts │ │ │ └── strategy │ │ │ │ └── local.strategy.ts │ │ ├── employee-data │ │ │ ├── employee-data.controller.ts │ │ │ ├── employee-data.module.ts │ │ │ └── employee-data.service.ts │ │ ├── main.ts │ │ ├── prisma │ │ │ ├── prisma.module.ts │ │ │ └── prisma.service.ts │ │ └── user │ │ │ ├── user.controller.ts │ │ │ ├── user.module.ts │ │ │ └── user.service.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── types.d.ts └── react │ ├── .gitignore │ ├── .prettierrc │ ├── index.html │ ├── package.json │ ├── postcss.config.cjs │ ├── public │ └── e-corp.svg │ ├── src │ ├── App.tsx │ ├── assets │ │ ├── image │ │ │ └── mask.png │ │ └── svg │ │ │ └── logo.svg │ ├── components │ │ ├── common │ │ │ ├── ProtectedRoute.tsx │ │ │ └── index.ts │ │ ├── layout │ │ │ ├── Layout.tsx │ │ │ ├── Navbar.tsx │ │ │ └── index.ts │ │ ├── table │ │ │ ├── EmployeeTable.tsx │ │ │ ├── RoleDialog.tsx │ │ │ └── index.ts │ │ └── ui │ │ │ ├── Avatar.tsx │ │ │ ├── Button.tsx │ │ │ ├── Dialog.tsx │ │ │ ├── Menu.tsx │ │ │ ├── Select.tsx │ │ │ └── index.ts │ ├── index.css │ ├── lib │ │ ├── api.ts │ │ └── utils.ts │ ├── main.tsx │ ├── pages │ │ ├── AuthPage.tsx │ │ ├── LogoutPage.tsx │ │ ├── MainPage.tsx │ │ ├── ProfilePage.tsx │ │ └── index.ts │ ├── store │ │ ├── app.store.ts │ │ ├── auth.store.ts │ │ └── index.ts │ └── vite-env.d.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── package.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none" 4 | } 5 | -------------------------------------------------------------------------------- /apps/api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module' 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended' 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off' 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /apps/api/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /apps/api/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /apps/api/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /apps/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "prisma": { 24 | "seed": "tsnd prisma/seed.ts" 25 | }, 26 | "dependencies": { 27 | "@nestjs/common": "^9.0.0", 28 | "@nestjs/core": "^9.0.0", 29 | "@nestjs/passport": "^9.0.3", 30 | "@nestjs/platform-express": "^9.0.0", 31 | "@prisma/client": "^4.10.1", 32 | "argon2": "^0.30.3", 33 | "class-transformer": "^0.5.1", 34 | "class-validator": "^0.14.0", 35 | "express-session": "^1.17.3", 36 | "nest-access-control": "^2.2.0", 37 | "passport": "^0.6.0", 38 | "passport-local": "^1.0.0", 39 | "prisma": "^4.10.1", 40 | "reflect-metadata": "^0.1.13", 41 | "rimraf": "^3.0.2", 42 | "rxjs": "^7.2.0" 43 | }, 44 | "devDependencies": { 45 | "@faker-js/faker": "^7.6.0", 46 | "@nestjs/cli": "^9.0.0", 47 | "@nestjs/schematics": "^9.0.0", 48 | "@nestjs/testing": "^9.0.0", 49 | "@types/express": "^4.17.13", 50 | "@types/express-session": "^1.17.6", 51 | "@types/jest": "28.1.4", 52 | "@types/node": "^16.0.0", 53 | "@types/passport-local": "^1.0.35", 54 | "@types/supertest": "^2.0.11", 55 | "@typescript-eslint/eslint-plugin": "^5.0.0", 56 | "@typescript-eslint/parser": "^5.0.0", 57 | "eslint": "^8.0.1", 58 | "eslint-config-prettier": "^8.3.0", 59 | "eslint-plugin-prettier": "^4.0.0", 60 | "jest": "28.1.2", 61 | "prettier": "^2.3.2", 62 | "source-map-support": "^0.5.20", 63 | "supertest": "^6.1.3", 64 | "ts-jest": "28.0.5", 65 | "ts-loader": "^9.2.3", 66 | "ts-node": "^10.0.0", 67 | "ts-node-dev": "^2.0.0", 68 | "tsconfig-paths": "4.0.0", 69 | "typescript": "^4.3.5" 70 | }, 71 | "jest": { 72 | "moduleFileExtensions": [ 73 | "js", 74 | "json", 75 | "ts" 76 | ], 77 | "rootDir": "src", 78 | "testRegex": ".*\\.spec\\.ts$", 79 | "transform": { 80 | "^.+\\.(t|j)s$": "ts-jest" 81 | }, 82 | "collectCoverageFrom": [ 83 | "**/*.(t|j)s" 84 | ], 85 | "coverageDirectory": "../coverage", 86 | "testEnvironment": "node" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /apps/api/prisma/dev.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladwulf/cwv-nestjs-rbac-tutorial/5d7d2c2021cd5d4aada6c8116dc3d1a8e855aed9/apps/api/prisma/dev.db -------------------------------------------------------------------------------- /apps/api/prisma/migrations/20230221190122_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" DATETIME NOT NULL, 6 | "username" TEXT NOT NULL, 7 | "password" TEXT NOT NULL, 8 | "fullName" TEXT NOT NULL, 9 | "salary" TEXT NOT NULL, 10 | "contactInfo" TEXT NOT NULL 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "Department" ( 15 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 16 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | "updatedAt" DATETIME NOT NULL, 18 | "name" TEXT NOT NULL 19 | ); 20 | 21 | -- CreateTable 22 | CREATE TABLE "UserDepartmentLink" ( 23 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 24 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 25 | "updatedAt" DATETIME NOT NULL, 26 | "role" TEXT NOT NULL, 27 | "jobTitle" TEXT NOT NULL, 28 | "userId" INTEGER NOT NULL, 29 | "departmentId" INTEGER NOT NULL, 30 | "assignedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 31 | "assignedBy" TEXT NOT NULL, 32 | CONSTRAINT "UserDepartmentLink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, 33 | CONSTRAINT "UserDepartmentLink_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 34 | ); 35 | 36 | -- CreateIndex 37 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 38 | 39 | -- CreateIndex 40 | CREATE UNIQUE INDEX "Department_name_key" ON "Department"("name"); 41 | -------------------------------------------------------------------------------- /apps/api/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /apps/api/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = "file:./dev.db" 8 | } 9 | 10 | model User { 11 | id Int @id @default(autoincrement()) 12 | createdAt DateTime @default(now()) 13 | updatedAt DateTime @updatedAt 14 | 15 | username String @unique 16 | password String 17 | fullName String 18 | salary String 19 | contactInfo String 20 | 21 | departmentsLink UserDepartmentLink[] 22 | } 23 | 24 | model Department { 25 | id Int @id @default(autoincrement()) 26 | createdAt DateTime @default(now()) 27 | updatedAt DateTime @updatedAt 28 | 29 | name String @unique 30 | 31 | usersLink UserDepartmentLink[] 32 | } 33 | 34 | model UserDepartmentLink { 35 | id Int @id @default(autoincrement()) 36 | createdAt DateTime @default(now()) 37 | updatedAt DateTime @updatedAt 38 | 39 | role String // USER, ADMIN, MANAGER 40 | jobTitle String 41 | 42 | userId Int 43 | user User @relation(fields: [userId], references: [id]) 44 | 45 | departmentId Int 46 | department Department @relation(fields: [departmentId], references: [id]) 47 | 48 | assignedAt DateTime @default(now()) 49 | assignedBy String 50 | } 51 | -------------------------------------------------------------------------------- /apps/api/prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { faker } from '@faker-js/faker'; 3 | import * as argon from 'argon2'; 4 | 5 | const departments = [ 6 | 'E Coin', 7 | 'E Phone', 8 | 'Bank of E Network', 9 | 'Sound & Fury Entertainment' 10 | ]; 11 | 12 | const jobTypes = [ 13 | 'Frontend Developer', 14 | 'Backend Developer', 15 | 'Devops', 16 | 'Tech Support' 17 | ]; 18 | 19 | export async function createRandomUser() { 20 | const firstName = faker.name.firstName(); 21 | const lastName = faker.name.lastName(); 22 | return { 23 | fullName: `${firstName} ${lastName}`, 24 | username: faker.internet.email(firstName, lastName, 'e-corp.com'), 25 | role: 'USER', 26 | password: await argon.hash('pwned'), 27 | workerRecord: { 28 | name: `${firstName} ${lastName}`, 29 | jobTitle: faker.helpers.arrayElement(jobTypes), 30 | department: faker.helpers.arrayElement(departments), 31 | salary: faker.random.numeric(6).toString(), 32 | contactInfo: faker.phone.number('(###) ###-####') 33 | } 34 | }; 35 | } 36 | 37 | async function main() { 38 | const prisma = new PrismaClient(); 39 | 40 | // clean db 41 | await prisma.$transaction([ 42 | prisma.userDepartmentLink.deleteMany(), 43 | prisma.department.deleteMany(), 44 | prisma.user.deleteMany() 45 | ]); 46 | 47 | // create departments 48 | for (const dep of departments) { 49 | await prisma.department.create({ 50 | data: { 51 | name: dep 52 | } 53 | }); 54 | } 55 | 56 | // create admin 57 | const dbDepartments = await prisma.department.findMany(); 58 | for (const dbDep of dbDepartments) { 59 | await prisma.department.update({ 60 | where: { 61 | id: dbDep.id 62 | }, 63 | data: { 64 | usersLink: { 65 | create: { 66 | role: 'ADMIN', 67 | jobTitle: 'CEO', 68 | assignedBy: 'system', 69 | user: { 70 | connectOrCreate: { 71 | create: { 72 | username: 'terry.colby@e-corp.com', 73 | fullName: 'Terry Colby', 74 | contactInfo: faker.phone.number('(###) ###-####'), 75 | password: await argon.hash('pwned'), 76 | salary: '200000' 77 | }, 78 | where: { 79 | username: 'terry.colby@e-corp.com' 80 | } 81 | } 82 | } 83 | } 84 | } 85 | } 86 | }); 87 | } 88 | 89 | // create manager 90 | const eCoinDep = dbDepartments.find((dep) => dep.name === 'E Coin'); 91 | await prisma.user.create({ 92 | data: { 93 | username: 'tyrell.wellick@e-corp.com', 94 | password: await argon.hash('pwned'), 95 | fullName: 'Tyrell Wellick', 96 | salary: '120000', 97 | contactInfo: faker.phone.number('(###) ###-####'), 98 | departmentsLink: { 99 | create: { 100 | role: 'MANAGER', 101 | jobTitle: 'Department Manager', 102 | assignedBy: 'system', 103 | departmentId: eCoinDep.id 104 | } 105 | } 106 | } 107 | }); 108 | 109 | // create users 110 | for (let i = 0; i <= 100; ++i) { 111 | const userData = await createRandomUser(); 112 | await prisma.userDepartmentLink.create({ 113 | data: { 114 | user: { 115 | create: { 116 | fullName: userData.fullName, 117 | username: userData.username, 118 | password: userData.password, 119 | salary: userData.workerRecord.salary, 120 | contactInfo: userData.workerRecord.contactInfo 121 | } 122 | }, 123 | role: 'USER', 124 | jobTitle: userData.workerRecord.jobTitle, 125 | assignedBy: 'system', 126 | department: { 127 | connect: { 128 | name: userData.workerRecord.department 129 | } 130 | } 131 | } 132 | }); 133 | } 134 | } 135 | 136 | main(); 137 | -------------------------------------------------------------------------------- /apps/api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthModule } from './auth/auth.module'; 3 | import { PrismaModule } from './prisma/prisma.module'; 4 | import { EmployeeDataModule } from './employee-data/employee-data.module'; 5 | import { APP_GUARD } from '@nestjs/core'; 6 | import { SessionGuard } from './auth/guards'; 7 | import { UserModule } from './user/user.module'; 8 | import { AccessControlModule, ACGuard } from 'nest-access-control'; 9 | import { RBAC_POLICY } from './auth/rbac-policy'; 10 | 11 | @Module({ 12 | imports: [ 13 | AccessControlModule.forRoles(RBAC_POLICY), 14 | AuthModule, 15 | PrismaModule, 16 | EmployeeDataModule, 17 | UserModule 18 | ], 19 | providers: [ 20 | { 21 | provide: APP_GUARD, 22 | useClass: SessionGuard 23 | }, 24 | { 25 | provide: APP_GUARD, 26 | useClass: ACGuard 27 | } 28 | ] 29 | }) 30 | export class AppModule {} 31 | -------------------------------------------------------------------------------- /apps/api/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | HttpCode, 4 | HttpStatus, 5 | Post, 6 | Req, 7 | Session, 8 | SetMetadata, 9 | UseGuards 10 | } from '@nestjs/common'; 11 | import { SessionData } from 'express-session'; 12 | import { Request } from 'express'; 13 | import { AuthGuard } from '@nestjs/passport'; 14 | 15 | @Controller('auth') 16 | export class AuthController { 17 | @SetMetadata('isPublic', true) 18 | @UseGuards(AuthGuard('local')) 19 | @HttpCode(HttpStatus.OK) 20 | @Post('/login') 21 | login(@Req() req: Request, @Session() session: SessionData) { 22 | session.user = { 23 | userId: req.user.userId, 24 | username: req.user.username, 25 | roles: req.user.roles 26 | }; 27 | return { 28 | status: HttpStatus.OK 29 | }; 30 | } 31 | 32 | @HttpCode(HttpStatus.NO_CONTENT) 33 | @Post('/logout') 34 | logout(@Req() req: Request) { 35 | return new Promise((resolve, reject) => { 36 | req.session.destroy((err) => { 37 | if (err) reject(err); 38 | resolve({ 39 | status: 204, 40 | message: 'Session destroyed' 41 | }); 42 | }); 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/api/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthController } from './auth.controller'; 3 | import { AuthService } from './auth.service'; 4 | import { LocalStrategy } from './strategy/local.strategy'; 5 | 6 | @Module({ 7 | controllers: [AuthController], 8 | providers: [AuthService, LocalStrategy] 9 | }) 10 | export class AuthModule {} 11 | -------------------------------------------------------------------------------- /apps/api/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PrismaService } from 'src/prisma/prisma.service'; 3 | import * as argon from 'argon2'; 4 | 5 | @Injectable() 6 | export class AuthService { 7 | constructor(private prisma: PrismaService) {} 8 | 9 | async validateUser(username: string, password: string) { 10 | const user = await this.prisma.user.findFirst({ 11 | where: { 12 | username 13 | }, 14 | include: { 15 | departmentsLink: { 16 | select: { 17 | role: true 18 | } 19 | } 20 | } 21 | }); 22 | if (!user) return null; 23 | 24 | const pwValid = await argon.verify(user.password, password); 25 | if (!pwValid) return null; 26 | 27 | return user; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/api/src/auth/enums/index.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | USER = 'USER', 3 | ADMIN = 'ADMIN', 4 | MANAGER = 'MANAGER' 5 | } 6 | -------------------------------------------------------------------------------- /apps/api/src/auth/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './roles.guard'; 2 | export * from './session.guard'; 3 | -------------------------------------------------------------------------------- /apps/api/src/auth/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | import { Reflector } from '@nestjs/core'; 4 | import { Role } from '../enums'; 5 | 6 | /** 7 | * @deprecated Roles guard is a simplistic RBAC approach where we just check whether the user has certain roles 8 | */ 9 | @Injectable() 10 | export class RolesGuard implements CanActivate { 11 | constructor(private reflector: Reflector) {} 12 | 13 | canActivate(context: ExecutionContext) { 14 | const requiredRoles = this.reflector.getAllAndOverride('roles', [ 15 | context.getHandler(), 16 | context.getClass() 17 | ]); 18 | 19 | if (!requiredRoles || requiredRoles.length === 0) return true; 20 | 21 | const { session } = context.switchToHttp().getRequest(); 22 | 23 | return requiredRoles.some((role) => session.user.roles.includes(role)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/api/src/auth/guards/session.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException 6 | } from '@nestjs/common'; 7 | import { Request } from 'express'; 8 | import { Reflector } from '@nestjs/core'; 9 | 10 | @Injectable() 11 | export class SessionGuard implements CanActivate { 12 | constructor(private reflector: Reflector) {} 13 | 14 | canActivate(context: ExecutionContext) { 15 | const isPublicRoute = this.reflector.getAllAndOverride('isPublic', [ 16 | context.getHandler(), 17 | context.getClass() 18 | ]); 19 | 20 | if (isPublicRoute) return true; 21 | 22 | const request = context.switchToHttp().getRequest(); 23 | if (request.session?.user?.userId) { 24 | request.user = request.session.user; 25 | return true; 26 | } 27 | 28 | throw new UnauthorizedException('Session not provided'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/api/src/auth/rbac-policy.ts: -------------------------------------------------------------------------------- 1 | import { RolesBuilder } from 'nest-access-control'; 2 | import { Role } from './enums'; 3 | 4 | export const RBAC_POLICY: RolesBuilder = new RolesBuilder(); 5 | 6 | // prettier-ignore 7 | RBAC_POLICY 8 | .grant(Role.USER) 9 | .readOwn('employeeData') 10 | .grant(Role.MANAGER) 11 | .extend(Role.USER) 12 | .read('managedEmployeeData') 13 | .read('employeeDetails') 14 | .grant(Role.ADMIN) 15 | .extend(Role.MANAGER) 16 | .read('employeeData') 17 | .update('employeeData') 18 | .delete('employeeData') 19 | .deny(Role.ADMIN) 20 | .read('managedEmployeeData') 21 | -------------------------------------------------------------------------------- /apps/api/src/auth/strategy/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { AuthService } from '../auth.service'; 5 | import { Role } from '../enums'; 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy, 'local') { 9 | constructor(private authService: AuthService) { 10 | super(); 11 | } 12 | 13 | async validate(username: string, password: string): Promise { 14 | const user = await this.authService.validateUser(username, password); 15 | if (!user) { 16 | throw new UnauthorizedException('Credentials incorrect'); 17 | } 18 | 19 | const userIsAdmin = user.departmentsLink.some( 20 | (link) => link.role === Role.ADMIN 21 | ); 22 | const userIsManager = user.departmentsLink.some( 23 | (link) => link.role === Role.MANAGER 24 | ); 25 | 26 | let userRole = Role.USER; 27 | if (userIsManager) userRole = Role.MANAGER; 28 | if (userIsAdmin) userRole = Role.ADMIN; 29 | 30 | return { 31 | userId: user.id, 32 | username: user.username, 33 | roles: [userRole] 34 | }; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /apps/api/src/employee-data/employee-data.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, ParseIntPipe, Session } from '@nestjs/common'; 2 | import { SessionData } from 'express-session'; 3 | import { UseRoles } from 'nest-access-control'; 4 | import { EmployeeDataService } from './employee-data.service'; 5 | 6 | @Controller('employee-data') 7 | export class EmployeeDataController { 8 | constructor(private employeeDataService: EmployeeDataService) {} 9 | 10 | @UseRoles({ 11 | resource: 'employeeData', 12 | action: 'read', 13 | possession: 'any' 14 | }) 15 | @Get('all') 16 | getAllEmployees() { 17 | return this.employeeDataService.getAllEmployees(); 18 | } 19 | 20 | @UseRoles({ 21 | resource: 'managedEmployeeData', 22 | action: 'read', 23 | possession: 'any' 24 | }) 25 | @Get() 26 | getManagedEmployees(@Session() session: SessionData) { 27 | return this.employeeDataService.getManagedEmployees(session.user.userId); 28 | } 29 | 30 | @UseRoles({ 31 | resource: 'employeeDetails', 32 | action: 'read', 33 | possession: 'any' 34 | }) 35 | @Get('employee/:employeeId') 36 | getEmployeeById(@Param('employeeId', ParseIntPipe) employeeId: number) { 37 | return this.employeeDataService.getEmployeeById(employeeId); 38 | } 39 | 40 | @UseRoles({ 41 | resource: 'employeeData', 42 | action: 'read', 43 | possession: 'any' 44 | }) 45 | @Get('sector/:sector') 46 | getEmployeesBySector( 47 | @Param('sector') sector: string, 48 | @Session() session: SessionData 49 | ) { 50 | return this.employeeDataService.getEmployeesBySector( 51 | session.user.userId, 52 | session.user.roles[0], 53 | sector 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apps/api/src/employee-data/employee-data.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EmployeeDataService } from './employee-data.service'; 3 | import { EmployeeDataController } from './employee-data.controller'; 4 | 5 | @Module({ 6 | providers: [EmployeeDataService], 7 | controllers: [EmployeeDataController] 8 | }) 9 | export class EmployeeDataModule {} 10 | -------------------------------------------------------------------------------- /apps/api/src/employee-data/employee-data.service.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException, Injectable } from '@nestjs/common'; 2 | import { Role } from 'src/auth/enums'; 3 | import { PrismaService } from 'src/prisma/prisma.service'; 4 | 5 | @Injectable() 6 | export class EmployeeDataService { 7 | constructor(private prisma: PrismaService) {} 8 | 9 | getAllEmployees() { 10 | return this.prisma.user.findMany({ 11 | where: { 12 | departmentsLink: { 13 | every: { 14 | role: { 15 | not: Role.ADMIN 16 | } 17 | } 18 | } 19 | }, 20 | select: { 21 | id: true, 22 | username: true, 23 | fullName: true, 24 | departmentsLink: { 25 | select: { 26 | role: true, 27 | department: true, 28 | jobTitle: true 29 | } 30 | }, 31 | contactInfo: true, 32 | salary: true 33 | } 34 | }); 35 | } 36 | 37 | async getManagedEmployees(managerId: number) { 38 | const departments = await this.prisma.user.findFirst({ 39 | where: { 40 | id: managerId 41 | }, 42 | select: { 43 | departmentsLink: { 44 | select: { 45 | role: true, 46 | jobTitle: true, 47 | department: { 48 | select: { 49 | name: true 50 | } 51 | } 52 | } 53 | } 54 | } 55 | }); 56 | 57 | const sections = departments.departmentsLink.map( 58 | (dep) => dep.department.name 59 | ); 60 | 61 | return this.prisma.user.findMany({ 62 | where: { 63 | departmentsLink: { 64 | every: { 65 | role: Role.USER, 66 | department: { 67 | name: { 68 | in: sections 69 | } 70 | } 71 | } 72 | } 73 | }, 74 | select: { 75 | id: true, 76 | username: true, 77 | fullName: true, 78 | departmentsLink: { 79 | select: { 80 | role: true, 81 | department: true, 82 | jobTitle: true 83 | } 84 | }, 85 | contactInfo: true, 86 | salary: true 87 | } 88 | }); 89 | } 90 | 91 | async getEmployeeById(employeeId: number) { 92 | return this.prisma.user.findFirst({ 93 | where: { 94 | id: employeeId 95 | }, 96 | select: { 97 | id: true, 98 | createdAt: true, 99 | fullName: true, 100 | salary: true, 101 | username: true, 102 | contactInfo: true, 103 | departmentsLink: { 104 | select: { 105 | jobTitle: true, 106 | role: true, 107 | assignedAt: true, 108 | assignedBy: true, 109 | department: { 110 | select: { 111 | id: true, 112 | name: true 113 | } 114 | } 115 | } 116 | } 117 | } 118 | }); 119 | } 120 | 121 | async getEmployeesBySector(userId: number, role: Role, sector: string) { 122 | if (role === Role.ADMIN) { 123 | return this.prisma.user.findMany({ 124 | where: { 125 | departmentsLink: { 126 | every: { 127 | role: Role.USER, 128 | department: { 129 | name: { 130 | contains: sector 131 | } 132 | } 133 | } 134 | } 135 | }, 136 | select: { 137 | id: true, 138 | username: true, 139 | fullName: true, 140 | departmentsLink: { 141 | select: { 142 | department: true, 143 | jobTitle: true 144 | } 145 | }, 146 | contactInfo: true, 147 | salary: true 148 | } 149 | }); 150 | } 151 | 152 | // check if user has access to the department 153 | const department = await this.prisma.department.findFirst({ 154 | where: { 155 | name: { 156 | contains: sector 157 | }, 158 | usersLink: { 159 | some: { 160 | role: Role.MANAGER, 161 | userId 162 | } 163 | } 164 | } 165 | }); 166 | if (!department) throw new ForbiddenException('Insufficient permissions'); 167 | 168 | return this.prisma.user.findMany({ 169 | where: { 170 | departmentsLink: { 171 | every: { 172 | role: Role.USER, 173 | department: { 174 | name: { 175 | contains: sector 176 | } 177 | } 178 | } 179 | } 180 | }, 181 | select: { 182 | id: true, 183 | username: true, 184 | fullName: true, 185 | departmentsLink: { 186 | select: { 187 | department: true, 188 | jobTitle: true 189 | } 190 | }, 191 | contactInfo: true, 192 | salary: true 193 | } 194 | }); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /apps/api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import * as session from 'express-session'; 4 | import { AppModule } from './app.module'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | 9 | app.use( 10 | session({ 11 | resave: false, 12 | saveUninitialized: false, 13 | name: 'session', 14 | secret: '128h381h38', 15 | cookie: { 16 | secure: false 17 | } 18 | }) 19 | ); 20 | 21 | app.enableCors({ 22 | origin: 'http://localhost:5173', 23 | credentials: true 24 | }); 25 | 26 | app.useGlobalPipes( 27 | new ValidationPipe({ 28 | forbidNonWhitelisted: true, 29 | transform: true 30 | }) 31 | ); 32 | 33 | await app.listen(3333); 34 | } 35 | bootstrap(); 36 | -------------------------------------------------------------------------------- /apps/api/src/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { PrismaService } from './prisma.service'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [PrismaService], 7 | exports: [PrismaService] 8 | }) 9 | export class PrismaModule {} 10 | -------------------------------------------------------------------------------- /apps/api/src/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | @Injectable() 5 | export class PrismaService extends PrismaClient { 6 | constructor() { 7 | super(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/api/src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post, Session } from '@nestjs/common'; 2 | import { SessionData } from 'express-session'; 3 | import { UseRoles } from 'nest-access-control'; 4 | import { UserService } from './user.service'; 5 | 6 | @Controller('user') 7 | export class UserController { 8 | constructor(private userService: UserService) {} 9 | @Get('me') 10 | getMe(@Session() session: SessionData) { 11 | return this.userService.getMe(session.user.userId); 12 | } 13 | 14 | @UseRoles({ 15 | resource: 'employeeData', 16 | action: 'update', 17 | possession: 'any' 18 | }) 19 | @Post('promote') 20 | promoteUserToManager(@Body('employeeId') employeeId: number) { 21 | return this.userService.promoteUserToManager(employeeId); 22 | } 23 | 24 | @UseRoles({ 25 | resource: 'employeeData', 26 | action: 'update', 27 | possession: 'any' 28 | }) 29 | @Post('demote') 30 | demoteManagerToUser(@Body('employeeId') employeeId: number) { 31 | return this.userService.demoteManagerToUser(employeeId); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/api/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { UserController } from './user.controller'; 4 | 5 | @Module({ 6 | providers: [UserService], 7 | controllers: [UserController] 8 | }) 9 | export class UserModule {} 10 | -------------------------------------------------------------------------------- /apps/api/src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { Role } from 'src/auth/enums'; 3 | import { PrismaService } from 'src/prisma/prisma.service'; 4 | 5 | @Injectable() 6 | export class UserService { 7 | constructor(private prisma: PrismaService) {} 8 | getMe(userId: number) { 9 | return this.prisma.user.findFirst({ 10 | where: { 11 | id: userId 12 | }, 13 | select: { 14 | id: true, 15 | createdAt: true, 16 | username: true, 17 | fullName: true, 18 | departmentsLink: { 19 | include: { 20 | department: true 21 | } 22 | } 23 | } 24 | }); 25 | } 26 | 27 | async promoteUserToManager(employeeId: number) { 28 | const departmentLink = await this.prisma.userDepartmentLink.findFirst({ 29 | where: { 30 | userId: employeeId 31 | } 32 | }); 33 | if (!departmentLink) throw new NotFoundException('Department not found'); 34 | await this.prisma.userDepartmentLink.update({ 35 | where: { 36 | id: departmentLink.id 37 | }, 38 | data: { 39 | role: Role.MANAGER 40 | } 41 | }); 42 | } 43 | 44 | async demoteManagerToUser(employeeId: number) { 45 | const departmentLink = await this.prisma.userDepartmentLink.findFirst({ 46 | where: { 47 | userId: employeeId 48 | } 49 | }); 50 | if (!departmentLink) throw new NotFoundException('Department not found'); 51 | await this.prisma.userDepartmentLink.update({ 52 | where: { 53 | id: departmentLink.id 54 | }, 55 | data: { 56 | role: Role.USER 57 | } 58 | }); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /apps/api/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | }, 21 | "include": ["./**/*", "./types.d.ts"], 22 | "exclude": ["node_modules", "dist"] 23 | } 24 | -------------------------------------------------------------------------------- /apps/api/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Role } from 'src/auth/enums'; 2 | 3 | declare module 'express-session' { 4 | interface SessionData { 5 | user: { 6 | userId: number; 7 | username: string; 8 | roles: [Role]; 9 | }; 10 | } 11 | } 12 | 13 | declare module 'express' { 14 | interface Request { 15 | user: { 16 | userId: number; 17 | username: string; 18 | roles: [Role]; 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /apps/react/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/react/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /apps/react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | E Corp 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-avatar": "^1.0.1", 13 | "@radix-ui/react-dialog": "^1.0.2", 14 | "@radix-ui/react-dropdown-menu": "^2.0.2", 15 | "@radix-ui/react-menubar": "^1.0.0", 16 | "@radix-ui/react-select": "^1.2.0", 17 | "class-variance-authority": "^0.4.0", 18 | "clsx": "^1.2.1", 19 | "lucide-react": "^0.115.0", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-hook-form": "^7.43.1", 23 | "react-hot-toast": "^2.4.0", 24 | "react-query": "^3.39.3", 25 | "react-router-dom": "^6.8.1", 26 | "tailwind-merge": "^1.10.0", 27 | "tailwindcss-animate": "^1.0.5", 28 | "zustand": "^4.3.3" 29 | }, 30 | "devDependencies": { 31 | "@types/react": "^18.0.27", 32 | "@types/react-dom": "^18.0.10", 33 | "@vitejs/plugin-react-swc": "^3.0.0", 34 | "autoprefixer": "^10.4.13", 35 | "postcss": "^8.4.21", 36 | "tailwindcss": "^3.2.7", 37 | "typescript": "^4.9.3", 38 | "vite": "^4.1.0", 39 | "vite-plugin-svgr": "^2.4.0" 40 | } 41 | } -------------------------------------------------------------------------------- /apps/react/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /apps/react/public/e-corp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /apps/react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider, createBrowserRouter } from 'react-router-dom' 2 | import { ProtectedRoute } from './components/common' 3 | import { AuthPage, LogoutPage, MainPage, ProfilePage } from './pages' 4 | import { Toaster } from 'react-hot-toast' 5 | 6 | const router = createBrowserRouter([ 7 | { 8 | path: '/', 9 | element: ( 10 | 11 | 12 | 13 | ) 14 | }, 15 | { 16 | path: '/auth', 17 | element: 18 | }, 19 | { 20 | path: '/Logout', 21 | element: ( 22 | 23 | 24 | 25 | ) 26 | }, 27 | { 28 | path: '/profile', 29 | element: ( 30 | 31 | 32 | 33 | ) 34 | } 35 | ]) 36 | 37 | function App() { 38 | return ( 39 | <> 40 | 41 | 42 | 43 | ) 44 | } 45 | 46 | export default App 47 | -------------------------------------------------------------------------------- /apps/react/src/assets/image/mask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladwulf/cwv-nestjs-rbac-tutorial/5d7d2c2021cd5d4aada6c8116dc3d1a8e855aed9/apps/react/src/assets/image/mask.png -------------------------------------------------------------------------------- /apps/react/src/assets/svg/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /apps/react/src/components/common/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Navigate } from 'react-router-dom' 3 | import { useAuthStore } from '~/store' 4 | 5 | export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { 6 | const userIsLoggedIn = useAuthStore((state) => state.user?.username) 7 | 8 | if (!userIsLoggedIn) return 9 | return
{children}
10 | } 11 | -------------------------------------------------------------------------------- /apps/react/src/components/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ProtectedRoute' 2 | -------------------------------------------------------------------------------- /apps/react/src/components/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import { Navbar } from './Navbar' 3 | 4 | export const Layout: React.FC<{ children?: ReactNode }> = ({ children }) => { 5 | return ( 6 |
7 | 8 |
9 | {children} 10 |
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /apps/react/src/components/layout/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { LogOut, User, LayoutDashboard } from 'lucide-react' 2 | import { Link, useNavigate } from 'react-router-dom' 3 | import { ReactComponent as LogoSvg } from '~/assets/svg/logo.svg' 4 | import { Avatar, AvatarFallback, AvatarImage } from '../ui' 5 | import { 6 | DropdownMenu, 7 | DropdownMenuTrigger, 8 | DropdownMenuContent, 9 | DropdownMenuLabel, 10 | DropdownMenuSeparator, 11 | DropdownMenuItem 12 | } from '../ui/Menu' 13 | 14 | import mask from '~/assets/image/mask.png' 15 | 16 | export const Navbar = () => { 17 | const navigate = useNavigate() 18 | return ( 19 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /apps/react/src/components/layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Layout'; 2 | -------------------------------------------------------------------------------- /apps/react/src/components/table/EmployeeTable.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import clsx from 'clsx' 3 | import { toast } from 'react-hot-toast' 4 | import { request } from '~/lib/api' 5 | import { useAuthStore } from '~/store' 6 | import { RoleDialog } from './RoleDialog' 7 | import { useAppStore } from '~/store/app.store' 8 | import { useQuery } from 'react-query' 9 | 10 | type EmployeeData = { 11 | id: number 12 | email: string 13 | name: string 14 | title: string 15 | department: string 16 | salary: string 17 | role: string 18 | } 19 | 20 | export const EmployeeTable = () => { 21 | const role = useAuthStore((state) => state.user?.role) 22 | const setEmployeeIdToEdit = useAppStore((state) => state.setEmployeeIdToEdit) 23 | 24 | const [employees, setEmployees] = useState([]) 25 | 26 | const { isSuccess } = useQuery( 27 | 'employees', 28 | () => { 29 | console.log({ role }) 30 | const url = role === 'ADMIN' ? '/employee-data/all' : '/employee-data' 31 | return request({ 32 | url, 33 | method: 'GET', 34 | toJSON: true 35 | }) 36 | }, 37 | { 38 | enabled: !!role, 39 | onSuccess(data) { 40 | console.log({ data }) 41 | const parsedData = data.map((data: any) => { 42 | return { 43 | id: data.id, 44 | name: data.fullName, 45 | email: data.username, 46 | title: data.departmentsLink[0].jobTitle, 47 | department: data.departmentsLink[0].department.name, 48 | salary: data.salary, 49 | role: data.departmentsLink[0].role 50 | } as EmployeeData 51 | }) 52 | setEmployees(parsedData) 53 | } 54 | } 55 | ) 56 | 57 | if (!isSuccess) return null 58 | 59 | return ( 60 | <> 61 | 62 |
63 | 64 | 65 | 66 | 69 | 72 | 75 | 78 | 81 | 84 | {role === 'ADMIN' && ( 85 | 88 | )} 89 | 90 | 91 | 92 | {employees.map((employee) => ( 93 | 100 | 106 | 107 | 108 | 109 | 110 | 111 | {role === 'ADMIN' && ( 112 | 123 | )} 124 | 125 | ))} 126 | 127 |
67 | Employee name 68 | 70 | Email 71 | 73 |
Title
74 |
76 |
Department
77 |
79 |
Role
80 |
82 |
Salary
83 |
86 |
Action
87 |
104 | {employee.name} 105 | {employee.email}{employee.title}{employee.department}{employee.role}{employee.salary} setEmployeeIdToEdit(employee.id)} 115 | > 116 | 120 | Edit 121 | 122 |
128 |
129 | 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /apps/react/src/components/table/RoleDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useAppStore } from '~/store/app.store' 2 | import { 3 | Button, 4 | Select, 5 | SelectContent, 6 | SelectItem, 7 | SelectTrigger, 8 | SelectValue 9 | } from '../ui' 10 | import { 11 | Dialog, 12 | DialogContent, 13 | DialogHeader, 14 | DialogTitle, 15 | DialogDescription 16 | } from '../ui/Dialog' 17 | import { useMutation, useQuery, useQueryClient } from 'react-query' 18 | import { request } from '~/lib/api' 19 | import { useState } from 'react' 20 | 21 | export const RoleDialog = () => { 22 | const queryClient = useQueryClient() 23 | const employeeIdToEdit = useAppStore((state) => state.employeeIdToEdit) 24 | const setEmployeeIdToEdit = useAppStore((state) => state.setEmployeeIdToEdit) 25 | 26 | const [currentUserRole, setCurrentUserRole] = useState('') 27 | const [newUserRole, setNewUserRole] = useState('') 28 | 29 | useQuery( 30 | ['employee', employeeIdToEdit], 31 | () => { 32 | return request({ 33 | url: `/employee-data/employee/${employeeIdToEdit}`, 34 | method: 'GET', 35 | toJSON: true 36 | }) 37 | }, 38 | { 39 | enabled: !!employeeIdToEdit, 40 | onSuccess(data) { 41 | setCurrentUserRole(data?.departmentsLink[0]?.role) 42 | } 43 | } 44 | ) 45 | 46 | const { mutate } = useMutation( 47 | (role: string) => { 48 | if (role === 'USER') { 49 | return request({ 50 | url: `/user/demote`, 51 | method: 'POST', 52 | body: { 53 | employeeId: employeeIdToEdit 54 | } 55 | }) 56 | // demote 57 | } else { 58 | // promote 59 | return request({ 60 | url: `/user/promote`, 61 | method: 'POST', 62 | body: { 63 | employeeId: employeeIdToEdit 64 | } 65 | }) 66 | } 67 | }, 68 | { 69 | onSuccess() { 70 | queryClient.refetchQueries('employees') 71 | handleOnCloseDropdown() 72 | } 73 | } 74 | ) 75 | 76 | function handleOnCloseDropdown() { 77 | setEmployeeIdToEdit(null) 78 | setNewUserRole('') 79 | setCurrentUserRole('') 80 | } 81 | 82 | if (!employeeIdToEdit) return null 83 | 84 | return ( 85 | !open && handleOnCloseDropdown()}> 86 | 87 | 88 | User Role 89 | 90 | You can change the user role. Changing from user to manager will 91 | promote the user to the manager role in its department. 92 | 93 | 94 |
95 |

Current role

96 | 105 |
106 | 107 |
108 | 117 |
118 |
119 |
120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /apps/react/src/components/table/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EmployeeTable' 2 | -------------------------------------------------------------------------------- /apps/react/src/components/ui/Avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import * as AvatarPrimitive from '@radix-ui/react-avatar' 5 | 6 | import { cn } from '~/lib/utils' 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /apps/react/src/components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { VariantProps, cva } from 'class-variance-authority'; 3 | 4 | import { cn } from '~/lib/utils'; 5 | 6 | const buttonVariants = cva( 7 | 'active:scale-95 inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:hover:bg-slate-800 dark:hover:text-slate-100 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800', 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900', 13 | destructive: 14 | 'bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600', 15 | outline: 16 | 'bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100', 17 | subtle: 18 | 'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100', 19 | ghost: 20 | 'bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent', 21 | link: 'bg-transparent dark:bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent' 22 | }, 23 | size: { 24 | default: 'h-10 py-2 px-4', 25 | sm: 'h-9 px-2 rounded-md', 26 | lg: 'h-11 px-8 rounded-md' 27 | } 28 | }, 29 | defaultVariants: { 30 | variant: 'default', 31 | size: 'default' 32 | } 33 | } 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps {} 39 | 40 | const Button = React.forwardRef( 41 | ({ className, variant, size, ...props }, ref) => { 42 | return ( 43 | 136 | 137 | 138 | 139 | 140 | 141 | ) 142 | } 143 | -------------------------------------------------------------------------------- /apps/react/src/pages/LogoutPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useAuthStore } from '~/store' 3 | import { useAppStore } from '~/store/app.store' 4 | 5 | export const LogoutPage = () => { 6 | const clearAuthStore = useAuthStore((state) => state.clear) 7 | const clearAppStore = useAppStore((state) => state.clear) 8 | 9 | useEffect(() => { 10 | fetch('http://localhost:3333/auth/logout', { 11 | method: 'POST', 12 | credentials: 'include' 13 | }).then(() => { 14 | clearAppStore() 15 | clearAuthStore() 16 | }) 17 | }, []) 18 | 19 | return null 20 | } 21 | -------------------------------------------------------------------------------- /apps/react/src/pages/MainPage.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from '~/components/layout' 2 | import { EmployeeTable } from '~/components/table' 3 | import { useAuthStore } from '~/store' 4 | 5 | export const MainPage = () => { 6 | const fullName = useAuthStore((state) => state.user?.fullName) 7 | const role = useAuthStore((state) => state.user?.role) 8 | return ( 9 | 10 |

Hello, {fullName}

11 | 12 | {role !== 'USER' && ( 13 | <> 14 |

Your employees

15 | 16 | 17 | )} 18 |
19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /apps/react/src/pages/ProfilePage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import toast from 'react-hot-toast' 3 | import { Layout } from '~/components/layout' 4 | import { request } from '~/lib/api' 5 | 6 | export const ProfilePage = () => { 7 | const [userData, setUserData] = useState({}) 8 | 9 | async function loadUserData() { 10 | try { 11 | const userData = await request({ 12 | url: '/user/me', 13 | method: 'GET', 14 | toJSON: true 15 | }) 16 | 17 | setUserData(userData) 18 | } catch (error: any) { 19 | toast.error(error.message) 20 | } 21 | } 22 | 23 | useEffect(() => { 24 | loadUserData() 25 | }, []) 26 | 27 | return ( 28 | 29 |

30 | Profile Information 31 |

32 |
33 |
34 |

Email

35 |

{userData.username}

36 |
37 |
38 |

Full Name

39 |

{userData.fullName}

40 |
41 |
42 |

Departments

43 |
44 | {userData?.departmentsLink?.map((link: any) => { 45 | return ( 46 |
47 |

Department: {link.department?.name}

48 |

Title: {link.jobTitle}

49 |

Role: {link.role}

50 |
51 | ) 52 | })} 53 |
54 |
55 |
56 |
57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /apps/react/src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthPage' 2 | export * from './MainPage' 3 | export * from './ProfilePage' 4 | export * from './LogoutPage' 5 | -------------------------------------------------------------------------------- /apps/react/src/store/app.store.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { persist, devtools } from 'zustand/middleware' 3 | 4 | interface AppState { 5 | employeeIdToEdit: number | null 6 | } 7 | 8 | interface AppActions { 9 | setEmployeeIdToEdit: (id: number | null) => void 10 | clear: () => void 11 | } 12 | 13 | interface AppStore extends AppState, AppActions {} 14 | 15 | const initialState: AppState = { 16 | employeeIdToEdit: null 17 | } 18 | 19 | export const useAppStore = create()( 20 | devtools( 21 | persist( 22 | (set) => ({ 23 | ...initialState, 24 | setEmployeeIdToEdit(id) { 25 | set({ 26 | employeeIdToEdit: id 27 | }) 28 | }, 29 | clear() { 30 | set({ 31 | ...initialState 32 | }) 33 | } 34 | }), 35 | { 36 | name: 'app-store', 37 | getStorage: () => localStorage 38 | } 39 | ), 40 | { 41 | name: 'app-store' 42 | } 43 | ) 44 | ) 45 | -------------------------------------------------------------------------------- /apps/react/src/store/auth.store.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand' 2 | import { persist, devtools } from 'zustand/middleware' 3 | 4 | interface User { 5 | id: number 6 | fullName: string 7 | username: string 8 | role: string 9 | } 10 | 11 | interface UserState { 12 | user?: User 13 | } 14 | 15 | interface UserActions { 16 | setUser: (user: User) => void 17 | clear: () => void 18 | } 19 | 20 | interface AuthStore extends UserState, UserActions {} 21 | 22 | const initialState: UserState = { 23 | user: undefined 24 | } 25 | 26 | export const useAuthStore = create()( 27 | devtools( 28 | persist( 29 | (set) => ({ 30 | ...initialState, 31 | setUser(user) { 32 | set({ 33 | user 34 | }) 35 | }, 36 | clear() { 37 | set({ 38 | ...initialState 39 | }) 40 | } 41 | }), 42 | { 43 | name: 'auth-store', 44 | getStorage: () => localStorage 45 | } 46 | ), 47 | { 48 | name: 'auth-store' 49 | } 50 | ) 51 | ) 52 | -------------------------------------------------------------------------------- /apps/react/src/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.store' 2 | -------------------------------------------------------------------------------- /apps/react/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /apps/react/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ['class', '[data-theme="dark"]'], 4 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 5 | theme: { 6 | extend: { 7 | keyframes: { 8 | 'accordion-down': { 9 | from: { height: 0 }, 10 | to: { height: 'var(--radix-accordion-content-height)' } 11 | }, 12 | 'accordion-up': { 13 | from: { height: 'var(--radix-accordion-content-height)' }, 14 | to: { height: 0 } 15 | } 16 | }, 17 | animation: { 18 | 'accordion-down': 'accordion-down 0.2s ease-out', 19 | 'accordion-up': 'accordion-up 0.2s ease-out' 20 | } 21 | } 22 | }, 23 | plugins: [require('tailwindcss-animate')] 24 | }; 25 | -------------------------------------------------------------------------------- /apps/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "src", 19 | "paths": { 20 | "~/*": ["./*"] 21 | } 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /apps/react/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/react/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import path from 'path'; 3 | import react from '@vitejs/plugin-react-swc'; 4 | import svgr from 'vite-plugin-svgr'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), svgr()], 9 | resolve: { 10 | alias: { 11 | '~': path.resolve(__dirname, './src') 12 | } 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cwv-nestjs-rbac", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC" 12 | } 13 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | --------------------------------------------------------------------------------