├── .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 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
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 |
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 |
67 | Employee name
68 | |
69 |
70 | Email
71 | |
72 |
73 | Title
74 | |
75 |
76 | Department
77 | |
78 |
79 | Role
80 | |
81 |
82 | Salary
83 | |
84 | {role === 'ADMIN' && (
85 |
86 | Action
87 | |
88 | )}
89 |
90 |
91 |
92 | {employees.map((employee) => (
93 |
100 |
104 | {employee.name}
105 | |
106 | {employee.email} |
107 | {employee.title} |
108 | {employee.department} |
109 | {employee.role} |
110 | {employee.salary} |
111 | {role === 'ADMIN' && (
112 | setEmployeeIdToEdit(employee.id)}
115 | >
116 |
120 | Edit
121 |
122 | |
123 | )}
124 |
125 | ))}
126 |
127 |
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 |
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 |
48 | );
49 | }
50 | );
51 | Button.displayName = 'Button';
52 |
53 | export { Button, buttonVariants };
54 |
--------------------------------------------------------------------------------
/apps/react/src/components/ui/Dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as DialogPrimitive from '@radix-ui/react-dialog'
5 | import { X } from 'lucide-react'
6 |
7 | import { cn } from '~/lib/utils'
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = ({
14 | className,
15 | children,
16 | ...props
17 | }: DialogPrimitive.DialogPortalProps) => (
18 |
19 |
20 | {children}
21 |
22 |
23 | )
24 | DialogPortal.displayName = DialogPrimitive.Portal.displayName
25 |
26 | const DialogOverlay = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, children, ...props }, ref) => (
30 |
38 | ))
39 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
40 |
41 | const DialogContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, children, ...props }, ref) => (
45 |
46 |
47 |
56 | {children}
57 |
58 |
59 | Close
60 |
61 |
62 |
63 | ))
64 | DialogContent.displayName = DialogPrimitive.Content.displayName
65 |
66 | const DialogHeader = ({
67 | className,
68 | ...props
69 | }: React.HTMLAttributes) => (
70 |
77 | )
78 | DialogHeader.displayName = 'DialogHeader'
79 |
80 | const DialogFooter = ({
81 | className,
82 | ...props
83 | }: React.HTMLAttributes) => (
84 |
91 | )
92 | DialogFooter.displayName = 'DialogFooter'
93 |
94 | const DialogTitle = React.forwardRef<
95 | React.ElementRef,
96 | React.ComponentPropsWithoutRef
97 | >(({ className, ...props }, ref) => (
98 |
107 | ))
108 | DialogTitle.displayName = DialogPrimitive.Title.displayName
109 |
110 | const DialogDescription = React.forwardRef<
111 | React.ElementRef,
112 | React.ComponentPropsWithoutRef
113 | >(({ className, ...props }, ref) => (
114 |
119 | ))
120 | DialogDescription.displayName = DialogPrimitive.Description.displayName
121 |
122 | export {
123 | Dialog,
124 | DialogTrigger,
125 | DialogContent,
126 | DialogHeader,
127 | DialogFooter,
128 | DialogTitle,
129 | DialogDescription
130 | }
131 |
--------------------------------------------------------------------------------
/apps/react/src/components/ui/Menu.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
5 | import { Check, ChevronRight, Circle } from 'lucide-react'
6 |
7 | import { cn } from '~/lib/utils'
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
183 | )
184 | }
185 | DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
186 |
187 | export {
188 | DropdownMenu,
189 | DropdownMenuTrigger,
190 | DropdownMenuContent,
191 | DropdownMenuItem,
192 | DropdownMenuCheckboxItem,
193 | DropdownMenuRadioItem,
194 | DropdownMenuLabel,
195 | DropdownMenuSeparator,
196 | DropdownMenuShortcut,
197 | DropdownMenuGroup,
198 | DropdownMenuPortal,
199 | DropdownMenuSub,
200 | DropdownMenuSubContent,
201 | DropdownMenuSubTrigger,
202 | DropdownMenuRadioGroup
203 | }
204 |
--------------------------------------------------------------------------------
/apps/react/src/components/ui/Select.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as React from 'react'
4 | import * as SelectPrimitive from '@radix-ui/react-select'
5 | import { Check, ChevronDown } from 'lucide-react'
6 |
7 | import { cn } from '~/lib/utils'
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 |
27 | {children}
28 |
29 |
30 | ))
31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
32 |
33 | const SelectContent = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, children, ...props }, ref) => (
37 |
38 |
46 |
47 | {children}
48 |
49 |
50 |
51 | ))
52 | SelectContent.displayName = SelectPrimitive.Content.displayName
53 |
54 | const SelectLabel = React.forwardRef<
55 | React.ElementRef,
56 | React.ComponentPropsWithoutRef
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | SelectLabel.displayName = SelectPrimitive.Label.displayName
68 |
69 | const SelectItem = React.forwardRef<
70 | React.ElementRef,
71 | React.ComponentPropsWithoutRef
72 | >(({ className, children, ...props }, ref) => (
73 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | {children}
88 |
89 | ))
90 | SelectItem.displayName = SelectPrimitive.Item.displayName
91 |
92 | const SelectSeparator = React.forwardRef<
93 | React.ElementRef,
94 | React.ComponentPropsWithoutRef
95 | >(({ className, ...props }, ref) => (
96 |
101 | ))
102 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
103 |
104 | export {
105 | Select,
106 | SelectGroup,
107 | SelectValue,
108 | SelectTrigger,
109 | SelectContent,
110 | SelectLabel,
111 | SelectItem,
112 | SelectSeparator
113 | }
114 |
--------------------------------------------------------------------------------
/apps/react/src/components/ui/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Avatar'
2 | export * from './Menu'
3 | export * from './Button'
4 | export * from './Select'
5 |
--------------------------------------------------------------------------------
/apps/react/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | @apply bg-gray-100;
7 | }
8 |
--------------------------------------------------------------------------------
/apps/react/src/lib/api.ts:
--------------------------------------------------------------------------------
1 | const baseUrl = 'http://localhost:3333'
2 |
3 | type FetchArgs = {
4 | url: string
5 | method: string
6 | headers?: Record
7 | body?: Record
8 | toJSON?: boolean
9 | preventLogoutOn401?: boolean
10 | }
11 | export async function request({
12 | url,
13 | method,
14 | headers,
15 | body,
16 | toJSON,
17 | preventLogoutOn401
18 | }: FetchArgs): Promise {
19 | try {
20 | const response = await fetch(baseUrl + url, {
21 | method: method,
22 | headers: headers ? headers : { 'Content-Type': 'application/json' },
23 | body: body ? JSON.stringify(body) : undefined,
24 | credentials: 'include'
25 | })
26 |
27 | // redirect to logout if status session dissapears
28 | if (!preventLogoutOn401 && response.status === 401)
29 | window.location.href = '/logout'
30 |
31 | if (toJSON) return response.json()
32 |
33 | return response
34 | } catch (error: any) {
35 | throw new Error(`Network Error: ${error.message}`)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/apps/react/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/apps/react/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import { QueryClient, QueryClientProvider } from 'react-query'
4 | import App from './App'
5 | import './index.css'
6 |
7 | const queryClient = new QueryClient()
8 |
9 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
10 |
11 |
12 |
13 |
14 |
15 | )
16 |
--------------------------------------------------------------------------------
/apps/react/src/pages/AuthPage.tsx:
--------------------------------------------------------------------------------
1 | import { useForm } from 'react-hook-form'
2 | import { toast } from 'react-hot-toast'
3 | import { useNavigate } from 'react-router-dom'
4 | import { ReactComponent as LogoSvg } from '~/assets/svg/logo.svg'
5 | import { Button } from '~/components/ui/Button'
6 | import { request } from '~/lib/api'
7 | import { useAuthStore } from '~/store'
8 |
9 | type FormData = {
10 | email: string
11 | password: string
12 | }
13 |
14 | export const AuthPage = () => {
15 | const { register, handleSubmit, reset } = useForm()
16 |
17 | const setUser = useAuthStore((state) => state.setUser)
18 | const navigate = useNavigate()
19 |
20 | async function handleOnLogin(data: FormData) {
21 | try {
22 | const loginReq = await request({
23 | url: '/auth/login',
24 | method: 'POST',
25 | headers: { 'Content-Type': 'application/json' },
26 | body: {
27 | username: data.email,
28 | password: data.password
29 | },
30 | preventLogoutOn401: true,
31 | toJSON: true
32 | })
33 | // catch http exceptions
34 | if (loginReq?.error) throw loginReq
35 |
36 | const userInfoReq = await request({
37 | url: '/user/me',
38 | method: 'GET',
39 | headers: { 'Content-Type': 'application/json' },
40 | preventLogoutOn401: true,
41 | toJSON: true
42 | })
43 | // catch http exceptions
44 | if (userInfoReq?.error) throw userInfoReq
45 |
46 | setUser({
47 | id: userInfoReq.id,
48 | username: userInfoReq.username,
49 | fullName: userInfoReq.fullName,
50 | role: userInfoReq.departmentsLink[0].role // FIXME: naive implementation
51 | })
52 | navigate('/')
53 | } catch (error: any) {
54 | toast.error(error.message)
55 | }
56 | }
57 | return (
58 |
59 |
60 |
64 | E Corp
65 |
66 |
67 |
68 |
69 | Sign in to your account
70 |
71 |
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 |
--------------------------------------------------------------------------------